From 2abddcd3d23e9dab536124f1ffad08ffa90bac52 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 5 Aug 2026 22:44:25 +0200 Subject: [PATCH 01/49] feat(multi-runner): add experimental lane config v2 --- modules/multi-runner/main.tf | 14 -- modules/multi-runner/multi-runner-config.tf | 121 ++++++++++ modules/multi-runner/queues.tf | 16 +- modules/multi-runner/runners.tf | 126 +++++------ .../multi-runner/variables.experimental.tf | 209 ++++++++++++++++++ modules/multi-runner/variables.tf | 4 +- modules/multi-runner/webhook.tf | 2 +- modules/webhook/variables.tf | 2 +- 8 files changed, 406 insertions(+), 88 deletions(-) create mode 100644 modules/multi-runner/multi-runner-config.tf create mode 100644 modules/multi-runner/variables.experimental.tf diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index bd96e30847..9d1274cb16 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -22,20 +22,6 @@ locals { webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) } - runner_extra_labels = { for k, v in var.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner_config.runner_extra_labels))) } - - runner_config = { 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[k] }) }), - ) } - - tmp_distinct_list_unique_os_and_arch = distinct([for i, config in local.runner_config : { "os_type" : config.runner_config.runner_os, "architecture" : config.runner_config.runner_architecture } if config.runner_config.enable_runner_binaries_syncer]) - unique_os_and_arch = { for i, v in local.tmp_distinct_list_unique_os_and_arch : "${v.os_type}_${v.architecture}" => v } - ssm_root_path = "/${var.ssm_paths.root}/${var.prefix}" } diff --git a/modules/multi-runner/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf new file mode 100644 index 0000000000..abd0b7b624 --- /dev/null +++ b/modules/multi-runner/multi-runner-config.tf @@ -0,0 +1,121 @@ +locals { + 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 + 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 + 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 = { + 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 + 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 + 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 + } + } + + 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 + } + } + + multi_runner_config = length(var.multi_runner_config_v2) > 0 ? 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))) + } + + 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.provider.type)) + runner = merge(v.runner, { runner_extra_labels = local.runner_extra_labels[k] }) + }) + } + + runner_matcher_config = { + for k, v in local.runner_config : k => { + id = v.id + arn = v.arn + runnerProvider = v.runnerProvider + matcherConfig = v.matcherConfig + } + } + + ec2_runner_config = { + 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 : { + "os_type" : config.runner.runner_os, + "architecture" : config.runner.runner_architecture + } + if config.provider.ec2.enable_runner_binaries_syncer + ]) + 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/queues.tf b/modules/multi-runner/queues.tf index bcc75f99cc..2b02010cd2 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -27,15 +27,15 @@ data "aws_iam_policy_document" "deny_insecure_transport" { } resource "aws_sqs_queue" "queued_builds" { - for_each = var.multi_runner_config + for_each = local.multi_runner_config name = "${var.prefix}-${each.key}-queued-builds" - delay_seconds = each.value.runner_config.delay_webhook_event + delay_seconds = each.value.queue.delay_webhook_event visibility_timeout_seconds = var.runners_scale_up_lambda_timeout - message_retention_seconds = each.value.runner_config.job_queue_retention_in_seconds + message_retention_seconds = each.value.queue.job_queue_retention_in_seconds receive_wait_time_seconds = 0 - redrive_policy = each.value.redrive_build_queue.enabled ? jsonencode({ + redrive_policy = each.value.queue.redrive_build_queue.enabled ? jsonencode({ deadLetterTargetArn = aws_sqs_queue.queued_builds_dlq[each.key].arn, - maxReceiveCount = each.value.redrive_build_queue.maxReceiveCount + maxReceiveCount = each.value.queue.redrive_build_queue.maxReceiveCount }) : null sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled @@ -46,13 +46,13 @@ resource "aws_sqs_queue" "queued_builds" { } resource "aws_sqs_queue_policy" "build_queue_policy" { - for_each = var.multi_runner_config + for_each = local.multi_runner_config queue_url = aws_sqs_queue.queued_builds[each.key].id policy = data.aws_iam_policy_document.deny_insecure_transport.json } resource "aws_sqs_queue" "queued_builds_dlq" { - for_each = { for config, values in var.multi_runner_config : config => values if values.redrive_build_queue.enabled } + for_each = { for config, values in local.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } name = "${var.prefix}-${each.key}-queued-builds_dead_letter" sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled @@ -62,7 +62,7 @@ resource "aws_sqs_queue" "queued_builds_dlq" { } resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { - for_each = { for config, values in var.multi_runner_config : config => values if values.redrive_build_queue.enabled } + for_each = { for config, values in local.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id policy = data.aws_iam_policy_document.deny_insecure_transport.json } diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 892113dcc7..35a714dd80 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,16 +1,16 @@ module "runners" { source = "../runners" - for_each = local.runner_config + for_each = local.ec2_runner_config aws_region = var.aws_region aws_partition = var.aws_partition - vpc_id = coalesce(each.value.runner_config.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.runner_config.subnet_ids, var.subnet_ids) + 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.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 + 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}" @@ -18,49 +18,49 @@ module "runners" { config = "${var.ssm_paths.runners}/config" } - 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_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_config.runner_architecture - ami = each.value.runner_config.ami + 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.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 + 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.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 + 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.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 + 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 @@ -68,8 +68,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.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_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 @@ -80,33 +80,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.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 + 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_config.scale_up_reserved_concurrent_executions + 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.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 + 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.runner_config.runner_ec2_tags + runner_ec2_tags = each.value.provider.ec2.runner_ec2_tags - create_service_linked_role_spot = each.value.runner_config.create_service_linked_role_spot + create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot - runner_iam_role_managed_policy_arns = each.value.runner_config.runner_iam_role_managed_policy_arns - iam_overrides = each.value.runner_config.iam_overrides + 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 @@ -116,15 +116,15 @@ module "runners" { log_level = var.log_level - pool_config = each.value.runner_config.pool_config + pool_config = each.value.runner.pool_config pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.runner_config.pool_runner_owner + 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_config.job_retry + job_retry = each.value.runner.job_retry metrics = var.metrics } diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf new file mode 100644 index 0000000000..a0ee5dd137 --- /dev/null +++ b/modules/multi-runner/variables.experimental.tf @@ -0,0 +1,209 @@ +variable "multi_runner_config_v2" { + description = < Date: Wed, 5 Aug 2026 20:45:15 +0000 Subject: [PATCH 02/49] docs: auto update terraform docs --- modules/multi-runner/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 21fc8f441b..06ca878634 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -174,7 +174,8 @@ module "multi-runner" { | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no | | [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
})
| `{}` | no | -| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, 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
})
}))
| n/a | yes | +| [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 | | [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 | From 61a6f0e8c77b4ecd27a01fa292f6347fb4dd3c11 Mon Sep 17 00:00:00 2001 From: Ederson Brilhante Date: Fri, 7 Aug 2026 21:12:43 +0200 Subject: [PATCH 03/49] refactor(multi-runner): decouple EC2 Terraform logic into provider modules (#5257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - Keep `modules/runners` and stable `multi_runner_config` dispatch unchanged. Stable configurations retain their historical `module.runners["configuration"]` addresses and flat `runners_map` fields. - Add explicit opt-in through `experimental.multi_runner_config_v2`. Stable and experimental configurations can coexist when their keys do not overlap; duplicate keys are rejected. - Normalize stable v1 once for shared queues, webhook matching, and runner-binary discovery while routing only v2 configurations through `modules/runner-stack`. - Make `runner-stack` the provider-neutral control plane for scale-up, scale-down, pool, job retry, SSM housekeeping, common Lambda IAM, and runner-role ownership. - Keep EC2-specific launch templates, instance profiles, security groups, AMI/bootstrap resources, runner log groups, IAM fragments, and Lambda environment fragments under `modules/compute-providers/ec2`. - Define provider-owned runner-role requirements in EC2 and attach them to the common runner role in `runner-stack`, allowing future compute providers to supply different policies without duplicating the role lifecycle. - Replace flat runner-stack inputs with ownership-based nested objects. Logging configuration is grouped under `observability.logs`, including `level`, retention, encryption, class, and tags. - Pass the canonical `compute_provider.ec2` object and nested `runner`, `github`, `ssm`, and `observability` objects directly into the EC2 resource and runner-role policy modules instead of expanding them back into prefixed scalar inputs. - Layer module, shared-resource, component, subcomponent, and EC2 runtime tags with documented precedence; provider-required EC2 bootstrap tags retain final precedence. - Group experimental v2 outputs by ownership: `runner.role`, `scale_up.{lambda,log_group,role}`, `scale_down.{lambda,log_group,role}`, nullable `pool.{lambda,log_group,role}`, and provider-specific resources under `provider.`. - Use caller-known optional wrappers for external AMI parameters and KMS keys. The wrapper determines Terraform graph shape while its `arn` leaf may remain unknown until apply. - Generate runner-stack, pool, job-retry, and EC2 IAM policies with `aws_iam_policy_document` and retain provider-policy merge behavior. - Document the experimental boundary, ownership model, plan-time wrapper pattern, phased migration, and nested output contract under the internal module documentation path. This draft is stacked on #5251 because the provider boundary consumes the experimental v2 normalization introduced there. Lambda/TypeScript terminology changes are tracked separately in #5258. ## Test Plan - `pre-commit run --all-files` — Terraform fmt, TFLint, validation, and merge-conflict checks passed. - `terraform test` in `modules/runner-stack` — 11 passed. - `terraform test` in `modules/multi-runner` — 7 passed. - `terraform test` in `modules/compute-providers/ec2` — 4 passed. - `terraform test` in `modules/compute-providers/ec2/runner-role` — 3 passed. - `terraform test` in `modules/runner-stack/pool` — 1 passed. - `terraform test` in `modules/runner-stack/job-retry` — 1 passed. - `terraform validate` in `modules/lambda` — passed. - Verified `modules/runners` has no diff from `origin/main`, stable v1 still dispatches only to `module.runners`, and only the experimental map dispatches to `module.runner_stacks`. - Verified computed external role, profile, AMI-parameter, managed-policy, and KMS ARN inputs plan successfully through the real wrapper fixture. No live AWS apply was performed. Terraform tests use mocked providers, and state migration is intentionally deferred to the later migration phase. ## Related Issues Closes #5252 Depends on #5251 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/terraform.yml | 12 + docs/index.md | 2 +- .../internal/compute-provider-refactor.md | 158 ++++ mkdocs.yaml | 1 + modules/compute-providers/ec2/README.md | 74 ++ .../compute-providers/ec2/control-plane.tf | 220 ++++++ .../compute-providers/ec2/instance-profile.tf | 9 + modules/compute-providers/ec2/logging.tf | 75 ++ modules/compute-providers/ec2/outputs.tf | 36 + .../compute-providers/ec2/policies-runner.tf | 206 ++++++ .../compute-providers/ec2/runner-config.tf | 13 + .../compute-providers/ec2/runner-instances.tf | 332 +++++++++ .../ec2/templates/cloudwatch_config.json | 12 + .../ec2/templates/install-runner-osx.sh | 61 ++ .../ec2/templates/install-runner.ps1 | 13 + .../ec2/templates/install-runner.sh | 73 ++ .../ec2/templates/start-runner-osx.sh | 185 +++++ .../ec2/templates/start-runner.ps1 | 206 ++++++ .../ec2/templates/start-runner.sh | 280 +++++++ .../ec2/templates/user-data-osx.sh | 39 + .../ec2/templates/user-data.ps1 | 47 ++ .../ec2/templates/user-data.sh | 81 +++ .../ec2/tests/provider.tftest.hcl | 410 +++++++++++ modules/compute-providers/ec2/variables.tf | 401 ++++++++++ modules/compute-providers/ec2/versions.tf | 10 + modules/multi-runner/README.md | 50 +- modules/multi-runner/multi-runner-config.tf | 284 ++++++-- modules/multi-runner/outputs.tf | 13 + modules/multi-runner/queues.tf | 4 +- modules/multi-runner/runners.experimental.tf | 183 +++++ modules/multi-runner/runners.tf | 129 ++-- .../tests/provider-routing.tftest.hcl | 684 ++++++++++++++++++ .../multi-runner/variables.experimental.tf | 579 ++++++++++----- modules/multi-runner/variables.tf | 2 +- modules/runner-stack/README.md | 129 ++++ modules/runner-stack/common-config.tf | 49 ++ modules/runner-stack/compute-provider.tf | 12 + modules/runner-stack/ec2.tf | 19 + modules/runner-stack/job-retry.tf | 62 ++ modules/runner-stack/job-retry/README.md | 61 ++ .../runner-stack/job-retry/iam-policies.tf | 104 +++ modules/runner-stack/job-retry/job-retry.tf | 177 +++++ modules/runner-stack/job-retry/outputs.tf | 13 + .../job-retry/tests/job-retry.tftest.hcl | 260 +++++++ modules/runner-stack/job-retry/variables.tf | 168 +++++ modules/runner-stack/job-retry/versions.tf | 10 + modules/runner-stack/outputs.tf | 29 + modules/runner-stack/pool.tf | 64 ++ modules/runner-stack/pool/README.md | 64 ++ modules/runner-stack/pool/iam-policies.tf | 66 ++ modules/runner-stack/pool/outputs.tf | 8 + modules/runner-stack/pool/pool.tf | 225 ++++++ .../pool/tests/provider.tftest.hcl | 135 ++++ modules/runner-stack/pool/variables.tf | 172 +++++ modules/runner-stack/pool/versions.tf | 10 + modules/runner-stack/runner-role.tf | 70 ++ modules/runner-stack/runner-ssm-parameters.tf | 28 + .../runner-stack/scale-down-state-diagram.md | 150 ++++ modules/runner-stack/scale-runners.tf | 86 +++ modules/runner-stack/scale-runners/README.md | 77 ++ .../scale-runners/common-config.tf | 20 + .../scale-runners/lambda-iam-policies.tf | 26 + modules/runner-stack/scale-runners/outputs.tf | 17 + .../scale-runners/scale-down-iam-policies.tf | 41 ++ .../runner-stack/scale-runners/scale-down.tf | 114 +++ .../scale-runners/scale-up-iam-policies.tf | 74 ++ .../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/ssm-housekeeper.tf | 57 ++ .../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/README.md | 72 ++ .../tests/computed-iam-inputs.tftest.hcl | 25 + .../fixtures/computed-iam-inputs/README.md | 39 + .../computed-iam-inputs.tf | 177 +++++ .../fixtures/computed-iam-inputs/versions.tf | 13 + modules/runner-stack/tests/pool.tftest.hcl | 361 +++++++++ modules/runner-stack/tests/tags.tftest.hcl | 301 ++++++++ .../variables.compute-provider.tf | 298 ++++++++ modules/runner-stack/variables.tf | 429 +++++++++++ modules/runner-stack/versions.tf | 10 + 88 files changed, 10145 insertions(+), 329 deletions(-) create mode 100644 docs/modules/internal/compute-provider-refactor.md create mode 100644 modules/compute-providers/ec2/README.md create mode 100644 modules/compute-providers/ec2/control-plane.tf create mode 100644 modules/compute-providers/ec2/instance-profile.tf create mode 100644 modules/compute-providers/ec2/logging.tf create mode 100644 modules/compute-providers/ec2/outputs.tf create mode 100644 modules/compute-providers/ec2/policies-runner.tf create mode 100644 modules/compute-providers/ec2/runner-config.tf create mode 100644 modules/compute-providers/ec2/runner-instances.tf create mode 100644 modules/compute-providers/ec2/templates/cloudwatch_config.json create mode 100644 modules/compute-providers/ec2/templates/install-runner-osx.sh create mode 100644 modules/compute-providers/ec2/templates/install-runner.ps1 create mode 100644 modules/compute-providers/ec2/templates/install-runner.sh create mode 100644 modules/compute-providers/ec2/templates/start-runner-osx.sh create mode 100644 modules/compute-providers/ec2/templates/start-runner.ps1 create mode 100644 modules/compute-providers/ec2/templates/start-runner.sh create mode 100644 modules/compute-providers/ec2/templates/user-data-osx.sh create mode 100644 modules/compute-providers/ec2/templates/user-data.ps1 create mode 100644 modules/compute-providers/ec2/templates/user-data.sh create mode 100644 modules/compute-providers/ec2/tests/provider.tftest.hcl create mode 100644 modules/compute-providers/ec2/variables.tf create mode 100644 modules/compute-providers/ec2/versions.tf create mode 100644 modules/multi-runner/runners.experimental.tf create mode 100644 modules/multi-runner/tests/provider-routing.tftest.hcl create mode 100644 modules/runner-stack/README.md create mode 100644 modules/runner-stack/common-config.tf create mode 100644 modules/runner-stack/compute-provider.tf create mode 100644 modules/runner-stack/ec2.tf 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/iam-policies.tf create mode 100644 modules/runner-stack/job-retry/job-retry.tf create mode 100644 modules/runner-stack/job-retry/outputs.tf create mode 100644 modules/runner-stack/job-retry/tests/job-retry.tftest.hcl 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/outputs.tf create mode 100644 modules/runner-stack/pool.tf create mode 100644 modules/runner-stack/pool/README.md create mode 100644 modules/runner-stack/pool/iam-policies.tf create mode 100644 modules/runner-stack/pool/outputs.tf create mode 100644 modules/runner-stack/pool/pool.tf create mode 100644 modules/runner-stack/pool/tests/provider.tftest.hcl 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-role.tf create mode 100644 modules/runner-stack/runner-ssm-parameters.tf create mode 100644 modules/runner-stack/scale-down-state-diagram.md 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 create mode 100644 modules/runner-stack/scale-runners/lambda-iam-policies.tf create mode 100644 modules/runner-stack/scale-runners/outputs.tf create mode 100644 modules/runner-stack/scale-runners/scale-down-iam-policies.tf create mode 100644 modules/runner-stack/scale-runners/scale-down.tf create mode 100644 modules/runner-stack/scale-runners/scale-up-iam-policies.tf 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 create mode 100644 modules/runner-stack/ssm-housekeeper.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 create mode 100644 modules/runner-stack/tests/README.md create mode 100644 modules/runner-stack/tests/computed-iam-inputs.tftest.hcl create mode 100644 modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md 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/tests/pool.tftest.hcl create mode 100644 modules/runner-stack/tests/tags.tftest.hcl create mode 100644 modules/runner-stack/variables.compute-provider.tf create mode 100644 modules/runner-stack/variables.tf create mode 100644 modules/runner-stack/versions.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 122ed88025..e5c2a54425 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -85,7 +85,12 @@ jobs: "download-lambda", "lambda", "multi-runner", + "compute-providers/ec2", "runner-binaries-syncer", + "runner-stack", + "runner-stack/job-retry", + "runner-stack/scale-runners", + "runner-stack/ssm-housekeeper", "runners", "setup-iam-permissions", "ssm", @@ -214,6 +219,13 @@ jobs: matrix: module: - 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 defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/index.md b/docs/index.md index 7a7d0f70c6..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 `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 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 new file mode 100644 index 0000000000..b3615217cd --- /dev/null +++ b/docs/modules/internal/compute-provider-refactor.md @@ -0,0 +1,158 @@ +# Experimental compute-provider refactor + +!!! warning "Experimental opt-in" + + 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 + +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 orchestration, provider-neutral control-plane components, and compute-provider implementations: + +| Layer | Owns | +| --- | --- | +| `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. | + +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` 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 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 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"] --> 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"] + Stack --> Housekeeper["runner-stack/ssm-housekeeper"] + Stack --> Provider["compute-providers/ec2"] + Provider --> Scaling + Provider --> Pool +``` + +The selected input is normalized once so shared resources can consume one representation. Stable normalization does not change stable runner dispatch: + +- 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. +- 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. +- 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. 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 + +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" + + # 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 = { + runner = { + os = "linux" + architecture = "arm64" + maximum_count = 2 + } + + compute_provider = { + 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.`. 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. + +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 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: + +```hcl +ssm = { + kms_key = { + arn = aws_kms_key.runner_parameters.arn + } +} + +compute_provider = { + ec2 = { + ami = { + id_ssm_parameter = { + arn = aws_ssm_parameter.runner_ami.arn + } + kms_key = { + arn = aws_kms_key.runner_ami.arn + } + } + } +} +``` + +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:** 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 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/mkdocs.yaml b/mkdocs.yaml index 9b98e84a36..6ec2922a2c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -65,6 +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): 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/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md new file mode 100644 index 0000000000..9cfe95aeb4 --- /dev/null +++ b/modules/compute-providers/ec2/README.md @@ -0,0 +1,74 @@ +# EC2 runner provider + +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 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. 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 + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_log_group.gh_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_instance_profile.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource | +| [aws_iam_policy.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_launch_template.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template) | resource | +| [aws_security_group.runner_sg](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_ssm_parameter.cloudwatch_agent_config_runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.runner_ami_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.runner_config_run_as](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.runner_enable_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.create_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.distribution_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.service_linked_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.session_manager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.terminate_self](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no | +| [aws\_region](#input\_aws\_region) | AWS region used 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 | +| [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 | +|------|-------------| +| [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-stack. | + diff --git a/modules/compute-providers/ec2/control-plane.tf b/modules/compute-providers/ec2/control-plane.tf new file mode 100644 index 0000000000..fd9213d00e --- /dev/null +++ b/modules/compute-providers/ec2/control-plane.tf @@ -0,0 +1,220 @@ +# EC2-specific IAM and environment fragments consumed by the common control +# plane in runner-stack. +data "aws_iam_policy_document" "ami_id_ssm_parameter_read" { + count = local.ami_id_ssm_external ? 1 : 0 + + statement { + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = [local.ami_id_ssm_parameter_arn] + } +} + +resource "aws_iam_policy" "ami_id_ssm_parameter_read" { + count = local.ami_id_ssm_external ? 1 : 0 + name = "${var.prefix}-ami-id-ssm-parameter-read" + path = local.role_path + description = "Allows for reading ${var.prefix} GitHub runner AMI ID from an SSM parameter" + tags = local.provider_tags + policy = data.aws_iam_policy_document.ami_id_ssm_parameter_read[0].json +} + +data "aws_iam_policy_document" "scale_up" { + statement { + effect = "Allow" + actions = [ + "ec2:DescribeInstances", + "ec2:DescribeLaunchTemplateVersions", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:CreateFleet", + "ec2:CreateTags", + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = ["ec2:TerminateInstances"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "ec2:ResourceTag/ghr:Application" + values = ["github-action-runner"] + } + } + + statement { + effect = "Allow" + actions = ["ec2:TerminateInstances"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "ec2:ResourceTag/ghr:environment" + values = [var.prefix] + } + } + + statement { + effect = "Allow" + actions = ["iam:PassRole"] + resources = [var.runner.iam.role.arn] + } + + statement { + effect = "Allow" + actions = ["ssm:GetParameter", "ssm:GetParameters"] + resources = [local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : local.ami_id_ssm_parameter_arn] + } + + dynamic "statement" { + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] + + content { + effect = "Allow" + actions = ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"] + resources = [statement.value] + } + } + + dynamic "statement" { + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] + + content { + effect = "Allow" + actions = ["kms:CreateGrant"] + resources = [statement.value] + + condition { + test = "Bool" + variable = "aws:ViaAWSService" + values = ["true"] + } + } + } +} + +data "aws_iam_policy_document" "scale_down" { + statement { + effect = "Allow" + actions = ["ec2:DescribeInstances", "ec2:DescribeTags"] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "ec2:ResourceTag/ghr:Application" + values = ["github-action-runner"] + } + } + + statement { + effect = "Allow" + actions = ["ec2:TerminateInstances", "ec2:CreateTags", "ec2:DeleteTags"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "ec2:ResourceTag/ghr:environment" + values = [var.prefix] + } + } +} + +data "aws_iam_policy_document" "pool" { + statement { + effect = "Allow" + actions = [ + "ec2:DescribeInstances", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:CreateFleet", + "ec2:CreateTags", + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = ["iam:PassRole"] + resources = [var.runner.iam.role.arn] + } + + statement { + effect = "Allow" + actions = ["ssm:GetParameters"] + resources = [local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : local.ami_id_ssm_parameter_arn] + } + + dynamic "statement" { + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] + + content { + effect = "Allow" + actions = ["kms:DescribeKey", "kms:ReEncrypt*", "kms:Decrypt"] + resources = [statement.value] + } + } + + dynamic "statement" { + for_each = local.ami_kms_key_enabled ? [local.ami_kms_key_arn] : [] + + content { + effect = "Allow" + actions = ["kms:CreateGrant"] + resources = [statement.value] + + condition { + test = "Bool" + variable = "aws:ViaAWSService" + values = ["true"] + } + } + } +} + +data "aws_iam_policy_document" "service_linked_role" { + count = var.config.create_service_linked_role_spot ? 1 : 0 + + statement { + effect = "Allow" + actions = ["iam:CreateServiceLinkedRole"] + resources = ["arn:${var.aws_partition}:iam::*:role/aws-service-role/*"] + } +} + +locals { + scale_up_environment_variables = { + AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name + INSTANCE_ALLOCATION_STRATEGY = var.config.instance_allocation_strategy + INSTANCE_MAX_SPOT_PRICE = var.config.instance_max_spot_price + INSTANCE_TARGET_CAPACITY_TYPE = var.config.instance_target_capacity_type + INSTANCE_TYPE_PRIORITIES = var.config.instance_type_priorities != null ? jsonencode(var.config.instance_type_priorities) : "" + INSTANCE_TYPES = join(",", var.config.instance_types) + LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name + SUBNET_IDS = join(",", var.config.subnet_ids) + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.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 + } + + 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.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 new file mode 100644 index 0000000000..68b8842d2d --- /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.config.instance_profile == null ? 1 : 0 + name = "${var.prefix}-runner-profile" + role = var.runner.iam.role.name + path = local.instance_profile_path + tags = local.provider_tags +} diff --git a/modules/compute-providers/ec2/logging.tf b/modules/compute-providers/ec2/logging.tf new file mode 100644 index 0000000000..00ae952e4d --- /dev/null +++ b/modules/compute-providers/ec2/logging.tf @@ -0,0 +1,75 @@ +# EC2 runner log collection and CloudWatch resources. +locals { + runner_log_files = ( + var.config.log_files != null + ? var.config.log_files + : [ + { + "prefix_log_group" : true, + "file_path" : "/var/log/messages", + "log_group_name" : "messages", + "log_stream_name" : "{instance_id}", + "log_class" : "STANDARD" + }, + { + "log_group_name" : "user_data", + "prefix_log_group" : true, + "file_path" : var.runner.os == "windows" ? "C:/UserData.log" : "/var/log/user-data.log", + "log_stream_name" : "{instance_id}", + "log_class" : "STANDARD" + }, + { + "log_group_name" : "runner", + "prefix_log_group" : true, + "file_path" : var.runner.os == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log", + "log_stream_name" : "{instance_id}", + "log_class" : "STANDARD" + }, + { + "log_group_name" : "runner-startup", + "prefix_log_group" : true, + "file_path" : var.runner.os == "windows" ? "C:/runner-startup.log" : "/var/log/runner-startup.log", + "log_stream_name" : "{instance_id}", + "log_class" : "STANDARD" + } + ] + ) + # CloudWatch agent collect_list schema expects log_group_class, not log_class + logfiles = var.config.cloudwatch_agent.enabled ? [for l in local.runner_log_files : { + "log_group_name" : l.prefix_log_group ? "/github-self-hosted-runners/${var.prefix}/${l.log_group_name}" : "/${l.log_group_name}" + "log_stream_name" : l.log_stream_name + "file_path" : l.file_path + "log_group_class" : l.log_class + }] : [] + + loggroups_names = distinct([for l in local.logfiles : l.log_group_name]) + # Create a list of unique log classes corresponding to each log group name + # This maintains the same order as loggroups_names for use with count + loggroups_classes = [ + for name in local.loggroups_names : [ + for l in local.logfiles : l.log_group_class + if l.log_group_name == name + ][0] + ] + +} + + +resource "aws_ssm_parameter" "cloudwatch_agent_config_runner" { + count = var.config.cloudwatch_agent.enabled ? 1 : 0 + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/cloudwatch_agent_config_runner" + type = "String" + value = var.config.cloudwatch_agent.config != null ? var.config.cloudwatch_agent.config : templatefile("${path.module}/templates/cloudwatch_config.json", { + logfiles = jsonencode(local.logfiles) + }) + tags = local.ssm_parameter_tags +} + +resource "aws_cloudwatch_log_group" "gh_runners" { + count = length(local.loggroups_names) + name = local.loggroups_names[count.index] + retention_in_days = var.observability.logs.retention_in_days + kms_key_id = var.observability.logs.kms_key_id + log_group_class = local.loggroups_classes[count.index] + tags = local.log_group_tags +} diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf new file mode 100644 index 0000000000..558a8b9610 --- /dev/null +++ b/modules/compute-providers/ec2/outputs.tf @@ -0,0 +1,36 @@ +output "provider" { + description = "Nested EC2 compute-provider contract consumed by runner-stack." + value = { + type = "ec2" + environment_variables = { + scale_up = local.scale_up_environment_variables + scale_down = local.scale_down_environment_variables + pool = local.pool_environment_variables + } + 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 + } + } + resources = { + launch_template = aws_launch_template.runner + runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, []) + logfiles = local.logfiles + } + } +} diff --git a/modules/compute-providers/ec2/policies-runner.tf b/modules/compute-providers/ec2/policies-runner.tf new file mode 100644 index 0000000000..785180cff3 --- /dev/null +++ b/modules/compute-providers/ec2/policies-runner.tf @@ -0,0 +1,206 @@ +# EC2 runner permission documents returned to runner-stack for attachment to +# the common runner role. +data "aws_caller_identity" "current" {} + +locals { + ssm_parameter_arn_prefix = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter" + ssm_config_arn = "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.config}" + cloudwatch_config_arn = "${local.ssm_config_arn}/cloudwatch_agent_config_runner" +} + +data "aws_iam_policy_document" "ssm_parameters" { + statement { + effect = "Allow" + actions = [ + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParameter", + ] + resources = [ + "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.tokens}/*", + ] + + condition { + test = "StringLike" + variable = "ec2:SourceInstanceARN" + values = ["*/&{aws:ResourceTag/InstanceId}"] + } + } + + statement { + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + ] + resources = [ + local.ssm_config_arn, + "${local.ssm_config_arn}/*", + ] + } +} + +data "aws_iam_policy_document" "session_manager" { + statement { + effect = "Allow" + actions = [ + "ssm:DescribeAssociation", + "ssm:GetDeployablePatchSnapshotForInstance", + "ssm:GetDocument", + "ssm:DescribeDocument", + "ssm:GetManifest", + "ssm:ListAssociations", + "ssm:ListInstanceAssociations", + "ssm:PutInventory", + "ssm:PutComplianceItems", + "ssm:PutConfigurePackageResult", + "ssm:UpdateAssociationStatus", + "ssm:UpdateInstanceAssociationStatus", + "ssm:UpdateInstanceInformation", + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "ec2messages:AcknowledgeMessage", + "ec2messages:DeleteMessage", + "ec2messages:FailMessage", + "ec2messages:GetEndpoint", + "ec2messages:GetMessages", + "ec2messages:SendReply", + ] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "distribution_bucket" { + count = var.config.binaries_syncer.enabled ? 1 : 0 + + statement { + sid = "githubActionDist" + effect = "Allow" + actions = ["s3:GetObject", "s3:GetObjectAcl"] + resources = ["${try(var.config.binaries_syncer.s3.arn, "")}/${try(var.config.binaries_syncer.s3.key, "")}"] + } +} + +data "aws_iam_policy_document" "describe_tags" { + statement { + effect = "Allow" + actions = ["ec2:DescribeTags"] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "create_tags" { + statement { + effect = "Allow" + actions = ["ec2:CreateTags"] + resources = ["arn:*:ec2:*:*:instance/*"] + + condition { + test = "ForAllValues:StringEquals" + variable = "aws:TagKeys" + values = ["ghr:github_runner_id"] + } + + condition { + test = "StringEquals" + variable = "aws:ARN" + values = ["&{ec2:SourceInstanceARN}"] + } + } +} + +data "aws_iam_policy_document" "terminate_self" { + statement { + effect = "Allow" + actions = ["ec2:TerminateInstances"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:ARN" + values = ["&{ec2:SourceInstanceARN}"] + } + } +} + +data "aws_iam_policy_document" "cloudwatch" { + count = var.config.cloudwatch_agent.enabled ? 1 : 0 + + statement { + effect = "Allow" + actions = [ + "cloudwatch:PutMetricData", + "ec2:DescribeVolumes", + "ec2:DescribeTags", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + "logs:DescribeLogGroups", + "logs:CreateLogStream", + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = ["ssm:GetParameter"] + resources = ["${local.cloudwatch_config_arn}/*"] + } +} + +locals { + runner_inline_policies = merge( + { + ssm_parameters = { + name = "runner-ssm-parameters" + policy_json = data.aws_iam_policy_document.ssm_parameters.json + } + describe_tags = { + name = "runner-describe-tags" + policy_json = data.aws_iam_policy_document.describe_tags.json + } + create_tags = { + name = "runner-create-tags" + policy_json = data.aws_iam_policy_document.create_tags.json + } + terminate_self = { + name = "ec2" + policy_json = data.aws_iam_policy_document.terminate_self.json + } + }, + var.config.ssm_enabled ? { + session_manager = { + name = "runner-ssm-session" + policy_json = data.aws_iam_policy_document.session_manager.json + } + } : {}, + var.config.binaries_syncer.enabled ? { + distribution_bucket = { + name = "distribution-bucket" + policy_json = data.aws_iam_policy_document.distribution_bucket[0].json + } + } : {}, + var.config.cloudwatch_agent.enabled ? { + cloudwatch = { + name = "CloudWatchLogginAndMetrics" + policy_json = data.aws_iam_policy_document.cloudwatch[0].json + } + } : {}, + ) +} diff --git a/modules/compute-providers/ec2/runner-config.tf b/modules/compute-providers/ec2/runner-config.tf new file mode 100644 index 0000000000..f1d859581c --- /dev/null +++ b/modules/compute-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.run_as_root ? "root" : var.runner.run_as + tags = local.ssm_parameter_tags +} + +resource "aws_ssm_parameter" "runner_enable_cloudwatch" { + name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_cloudwatch" + type = "String" + value = var.config.cloudwatch_agent.enabled + tags = local.ssm_parameter_tags +} diff --git a/modules/compute-providers/ec2/runner-instances.tf b/modules/compute-providers/ec2/runner-instances.tf new file mode 100644 index 0000000000..a4cd44b4ba --- /dev/null +++ b/modules/compute-providers/ec2/runner-instances.tf @@ -0,0 +1,332 @@ +# AMI selection, bootstrap rendering, launch template, and security group for +# EC2 runner instances. +locals { + provider_tags = merge( + { + "Name" = format("%s-action-runner", var.prefix) + }, + var.tags, + ) + + ssm_parameter_tags = merge( + local.provider_tags, + var.ssm.tags, + var.ssm.parameters.tags, + ) + + log_group_tags = merge( + local.provider_tags, + var.observability.logs.tags, + ) + + name_sg = var.config.overrides.name_sg == "" ? local.provider_tags["Name"] : var.config.overrides.name_sg + name_runner = var.config.overrides.name_runner == "" ? local.provider_tags["Name"] : var.config.overrides.name_runner + runner_tags = merge( + local.provider_tags, + { + "Name" = local.name_runner + }, + var.config.tags, + { + "ghr:environment" = var.prefix + "ghr:ssm_config_path" = "${var.ssm.paths.root}/${var.ssm.paths.config}" + "ghr:runner_name_prefix" = var.runner.name_prefix + }, + ) + + role_path = var.runner.iam.path == null ? "/${var.prefix}/" : var.runner.iam.path + instance_profile_path = var.config.instance_profile_path == null ? "/${var.prefix}/" : var.config.instance_profile_path + userdata_template = var.config.user_data.template == null ? local.default_userdata_template[var.runner.os] : var.config.user_data.template + s3_location_runner_distribution = var.config.binaries_syncer.enabled ? "s3://${try(var.config.binaries_syncer.s3.id, "")}/${try(var.config.binaries_syncer.s3.key, "")}" : "" + default_ami = { + "windows" = { name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"] } + "linux" = var.runner.architecture == "arm64" ? { name = ["al2023-ami-2023.*-kernel-6.*-arm64"] } : { name = ["al2023-ami-2023.*-kernel-6.*-x86_64"] } + "osx" = var.runner.architecture == "arm64" ? { name = ["amzn-ec2-macos-15.*-arm64"] } : { name = ["amzn-ec2-macos-15.*"] } + } + + default_userdata_template = { + "windows" = "${path.module}/templates/user-data.ps1" + "linux" = "${path.module}/templates/user-data.sh" + "osx" = "${path.module}/templates/user-data-osx.sh" + } + + userdata_install_runner = { + "windows" = "${path.module}/templates/install-runner.ps1" + "linux" = "${path.module}/templates/install-runner.sh" + "osx" = "${path.module}/templates/install-runner-osx.sh" + } + + userdata_start_runner = { + "windows" = "${path.module}/templates/start-runner.ps1" + "linux" = "${path.module}/templates/start-runner.sh" + "osx" = "${path.module}/templates/start-runner-osx.sh" + } + + # Handle AMI configuration + ami_config = var.config.ami != null ? var.config.ami : { + filter = local.default_ami[var.runner.os] + owners = ["amazon"] + id_ssm_parameter = null + kms_key = null + } + ami_kms_key_enabled = local.ami_config.kms_key != null + ami_kms_key_arn = local.ami_kms_key_enabled ? local.ami_config.kms_key.arn : null + ami_filter = merge(local.default_ami[var.runner.os], local.ami_config.filter) + ami_id_ssm_external = local.ami_config.id_ssm_parameter != null + ami_id_ssm_module_managed = !local.ami_id_ssm_external + ami_id_ssm_parameter_arn = local.ami_id_ssm_external ? local.ami_config.id_ssm_parameter.arn : null + # Extract parameter name from ARN (format: arn:aws:ssm:region:account:parameter/path/to/param) + ami_id_ssm_parameter_name = local.ami_id_ssm_external ? try(regex("parameter(/.+)$", local.ami_id_ssm_parameter_arn)[0], null) : null + + user_data = var.config.user_data.enabled ? (var.config.user_data.content == null ? templatefile(local.userdata_template, { + enable_debug_logging = var.config.user_data.debug_logging_enabled + s3_location_runner_distribution = local.s3_location_runner_distribution + pre_install = var.config.user_data.pre_install + install_runner = templatefile(local.userdata_install_runner[var.runner.os], { + S3_LOCATION_RUNNER_DISTRIBUTION = local.s3_location_runner_distribution + RUNNER_ARCHITECTURE = var.runner.architecture + }) + post_install = var.config.user_data.post_install + hook_job_started = var.runner.hooks.job_started + hook_job_completed = var.runner.hooks.job_completed + start_runner = templatefile(local.userdata_start_runner[var.runner.os], { + metadata_tags = var.config.metadata_options != null ? var.config.metadata_options.instance_metadata_tags : "enabled" + }) + ghes_url = var.github.enterprise_server.url + ghes_ssl_verify = var.github.enterprise_server.ssl_verify + + ## retain these for backwards compatibility + environment = var.prefix + enable_cloudwatch_agent = var.config.cloudwatch_agent.enabled + ssm_key_cloudwatch_agent_config = var.config.cloudwatch_agent.enabled ? aws_ssm_parameter.cloudwatch_agent_config_runner[0].name : "" + }) : var.config.user_data.content) : "" + + encoded_user_data = ( + var.runner.os == "linux" ? base64gzip(local.user_data) : + var.runner.os == "windows" ? base64encode(local.user_data) : + var.runner.os == "osx" ? base64encode(local.user_data) : + null + ) +} + +data "aws_ami" "runner" { + 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.provider_tags, + local.ssm_parameter_tags, + { + # Remove parentheses from AMI name to comply with AWS tag constraints + "ghr:ami_name" = replace(data.aws_ami.runner.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" + + lifecycle { + precondition { + 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.config.block_device_mappings != null ? var.config.block_device_mappings : [] + content { + device_name = block_device_mappings.value.device_name + + ebs { + delete_on_termination = block_device_mappings.value.delete_on_termination + encrypted = block_device_mappings.value.encrypted + iops = block_device_mappings.value.iops + kms_key_id = block_device_mappings.value.kms_key_id + snapshot_id = block_device_mappings.value.snapshot_id + throughput = block_device_mappings.value.throughput + volume_initialization_rate = block_device_mappings.value.volume_initialization_rate + volume_size = block_device_mappings.value.volume_size + volume_type = block_device_mappings.value.volume_type + } + } + } + + dynamic "metadata_options" { + for_each = var.config.metadata_options != null ? [var.config.metadata_options] : [] + + content { + http_endpoint = metadata_options.value.http_endpoint + http_tokens = metadata_options.value.http_tokens + http_put_response_hop_limit = metadata_options.value.http_put_response_hop_limit + instance_metadata_tags = metadata_options.value.instance_metadata_tags + } + } + + dynamic "metadata_options" { + for_each = var.config.metadata_options != null ? [] : [0] + + content { + instance_metadata_tags = "enabled" + } + } + + dynamic "credit_specification" { + for_each = var.config.credit_specification != null ? [var.config.credit_specification] : [] + content { + cpu_credits = credit_specification.value + } + } + + dynamic "cpu_options" { + for_each = var.config.cpu_options != null ? [var.config.cpu_options] : [] + content { + core_count = try(cpu_options.value.core_count, null) + threads_per_core = try(cpu_options.value.threads_per_core, null) + amd_sev_snp = try(cpu_options.value.amd_sev_snp, null) + nested_virtualization = try(cpu_options.value.nested_virtualization, null) + } + } + + dynamic "placement" { + for_each = var.config.placement != null ? [var.config.placement] : [] + content { + affinity = try(placement.value.affinity, null) + availability_zone = try(placement.value.availability_zone, null) + group_id = try(placement.value.group_id, null) + group_name = try(placement.value.group_name, null) + host_id = try(placement.value.host_id, null) + host_resource_group_arn = try(placement.value.host_resource_group_arn, null) + spread_domain = try(placement.value.spread_domain, null) + tenancy = try(placement.value.tenancy, null) + partition_number = try(placement.value.partition_number, null) + } + } + + dynamic "license_specification" { + for_each = var.config.license_specifications + content { + license_configuration_arn = license_specification.value.license_configuration_arn + } + } + + monitoring { + enabled = var.config.detailed_monitoring_enabled + } + + iam_instance_profile { + name = var.config.instance_profile != null ? var.config.instance_profile.name : aws_iam_instance_profile.runner[0].name + } + + instance_initiated_shutdown_behavior = "terminate" + image_id = "resolve:ssm:${local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : local.ami_id_ssm_parameter_arn}" + key_name = var.config.key_name + ebs_optimized = var.config.ebs_optimized + + vpc_security_group_ids = !var.config.associate_public_ipv4_address ? compact(concat( + var.config.managed_security_group_enabled ? [aws_security_group.runner_sg[0].id] : [], + var.config.additional_security_group_ids, + )) : [] + + tag_specifications { + resource_type = "instance" + tags = local.runner_tags + } + + tag_specifications { + resource_type = "volume" + tags = local.runner_tags + } + + # We avoid including the "spot-instances-request" tag_specifications block when on_demand_failover_for_errors is defined, + # because when using on-demand fallback, the spot instance request resource is not created and thus the tags would not apply. + # Additionally, tagging spot requests via the CreateFleetCommand in the Lambda function does not work as expected, + # so we rely on Terraform to manage these tags only when spot is exclusively used without on-demand failover. + dynamic "tag_specifications" { + for_each = var.config.instance_target_capacity_type == "spot" && length(var.config.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 + } + } + + tag_specifications { + resource_type = "network-interface" + tags = local.runner_tags + } + + user_data = local.encoded_user_data + + tags = local.provider_tags + + update_default_version = true + + dynamic "network_interfaces" { + for_each = var.config.associate_public_ipv4_address ? [var.config.associate_public_ipv4_address] : [] + iterator = associate_public_ipv4_address + content { + associate_public_ip_address = associate_public_ipv4_address.value + security_groups = compact(concat( + var.config.managed_security_group_enabled ? [aws_security_group.runner_sg[0].id] : [], + var.config.additional_security_group_ids, + )) + } + } +} + +resource "aws_security_group" "runner_sg" { + count = var.config.managed_security_group_enabled ? 1 : 0 + name_prefix = "${var.prefix}-github-actions-runner-sg" + description = "Github Actions Runner security group" + + vpc_id = var.config.vpc_id + + ingress = [] + + dynamic "egress" { + for_each = var.config.egress_rules + iterator = each + + content { + cidr_blocks = each.value.cidr_blocks + ipv6_cidr_blocks = each.value.ipv6_cidr_blocks + prefix_list_ids = each.value.prefix_list_ids + from_port = each.value.from_port + protocol = each.value.protocol + security_groups = each.value.security_groups + self = each.value.self + to_port = each.value.to_port + description = each.value.description + } + } + + tags = merge( + local.provider_tags, + { + "Name" = format("%s", local.name_sg) + }, + ) +} diff --git a/modules/compute-providers/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/compute-providers/ec2/templates/install-runner-osx.sh b/modules/compute-providers/ec2/templates/install-runner-osx.sh new file mode 100644 index 0000000000..ed848dad27 --- /dev/null +++ b/modules/compute-providers/ec2/templates/install-runner-osx.sh @@ -0,0 +1,61 @@ +# shellcheck shell=bash + +set -euo pipefail + +## install the runner (macOS) + +s3_location=${S3_LOCATION_RUNNER_DISTRIBUTION} +architecture=${RUNNER_ARCHITECTURE} + +if [ -z "$RUNNER_TARBALL_URL" ] && [ -z "$s3_location" ]; then + echo "Neither RUNNER_TARBALL_URL or s3_location are set" + exit 1 +fi + +file_name="actions-runner.tar.gz" + +echo "Setting up GH Actions runner tool cache" +mkdir -p /Users/runner/hostedtoolcache + +echo "Creating actions-runner directory for the GH Action installation" +sudo mkdir -p /opt/actions-runner +cd /opt/actions-runner || exit 1 + +if [[ -n "$runner_tarball_url" ]]; then + echo "Downloading the GH Action runner from $runner_tarball_url to $file_name" + curl -s -o "$file_name" -L "$runner_tarball_url" +else + echo "Retrieving REGION from AWS API" + token="$(curl -s -f -X PUT "http://169.254.169.254/latest/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 180")" + + region="$(curl -s -f -H "X-aws-ec2-metadata-token: $token" \ + http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)" + echo "Retrieved REGION from AWS API ($region)" + + echo "Downloading the GH Action runner from s3 bucket $s3_location" + aws s3 cp "$s3_location" "$file_name" --region "$region" --no-progress +fi + +echo "Un-tar action runner" +tar xzf "./$file_name" +echo "Delete tar file" +rm -rf "$file_name" + +os_name=$(sw_vers -productName 2>/dev/null || echo "macOS") +os_version=$(sw_vers -productVersion 2>/dev/null || echo "unknown") +arch_name=$(uname -m) + +echo "OS: $os_name $os_version ($arch_name)" + +if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew not found; skipping dependency installation via brew" +else + echo "Homebrew detected; install any macOS-specific dependencies here if needed" + # Example: brew install jq awscli +fi + +echo "Set file ownership of action runner" +sudo chown -R "$user_name":staff /opt/actions-runner +sudo chmod 755 "/Users/runner" +sudo chown -R "$user_name":staff /Users/runner/hostedtoolcache diff --git a/modules/compute-providers/ec2/templates/install-runner.ps1 b/modules/compute-providers/ec2/templates/install-runner.ps1 new file mode 100644 index 0000000000..a13f91a65b --- /dev/null +++ b/modules/compute-providers/ec2/templates/install-runner.ps1 @@ -0,0 +1,13 @@ +## install the runner + +Write-Host "Creating actions-runner directory for the GH Action installation" +New-Item -ItemType Directory -Path C:\actions-runner ; Set-Location C:\actions-runner + +Write-Host "Downloading the GH Action runner from s3 bucket $s3_location" +aws s3 cp ${S3_LOCATION_RUNNER_DISTRIBUTION} actions-runner.zip + +Write-Host "Un-zip action runner" +Expand-Archive -Path actions-runner.zip -DestinationPath . + +Write-Host "Delete zip file" +Remove-Item actions-runner.zip diff --git a/modules/compute-providers/ec2/templates/install-runner.sh b/modules/compute-providers/ec2/templates/install-runner.sh new file mode 100644 index 0000000000..5ed5897e7c --- /dev/null +++ b/modules/compute-providers/ec2/templates/install-runner.sh @@ -0,0 +1,73 @@ +# shellcheck shell=bash + +## install the runner + +s3_location=${S3_LOCATION_RUNNER_DISTRIBUTION} + +if [ -z "$RUNNER_TARBALL_URL" ] && [ -z "$s3_location" ]; then + echo "Neither RUNNER_TARBALL_URL or s3_location are set" + exit 1 +fi + +file_name="actions-runner.tar.gz" + +echo "Setting up GH Actions runner tool cache" +# Required for various */setup-* actions to work, location is also know by various environment +# variable names in the actions/runner software : RUNNER_TOOL_CACHE / RUNNER_TOOLSDIRECTORY / AGENT_TOOLSDIRECTORY +# Warning, not all setup actions support the env vars and so this specific path must be created regardless +mkdir -p /opt/hostedtoolcache + +echo "Creating actions-runner directory for the GH Action installation" +cd /opt/ +mkdir -p actions-runner && cd actions-runner + + +if [[ -n "$RUNNER_TARBALL_URL" ]]; then + echo "Downloading the GH Action runner from $RUNNER_TARBALL_URL to $file_name" + curl -s -o $file_name -L "$RUNNER_TARBALL_URL" +else + echo "Retrieving TOKEN from AWS API" + token="$(curl -s -f -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 180")" + + region="$(curl -s -f -H "X-aws-ec2-metadata-token: $token" http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)" + echo "Retrieved REGION from AWS API ($region)" + + echo "Downloading the GH Action runner from s3 bucket $s3_location" + aws s3 cp "$s3_location" "$file_name" --region "$region" --no-progress +fi + +echo "Un-tar action runner" +tar xzf ./$file_name +echo "Delete tar file" +rm -rf $file_name + +os_id=$(awk -F= '/^ID=/{print $2}' /etc/os-release) +echo OS: $os_id + +# Install libicu on non-ubuntu, non-debian +if [[ ! "$os_id" =~ ^(ubuntu|debian).* ]]; then + max_attempts=5 + attempt_count=0 + success=false + while [ $success = false ] && [ $attempt_count -le $max_attempts ]; do + echo "Attempt $attempt_count/$max_attempts: Installing libicu" + dnf install -y libicu + if [ $? -eq 0 ]; then + success=true + else + echo "Failed to install libicu" + attempt_count=$(( attempt_count + 1 )) + sleep 5 + fi + done +fi + +# Install dependencies for ubuntu and debian +if [[ "$os_id" =~ ^(ubuntu|debian).* ]]; then + echo "Installing dependencies" + ./bin/installdependencies.sh +fi + +echo "Set file ownership of action runner" +chown -R "$user_name":"$user_name" /opt/actions-runner +chown -R "$user_name":"$user_name" /opt/hostedtoolcache diff --git a/modules/compute-providers/ec2/templates/start-runner-osx.sh b/modules/compute-providers/ec2/templates/start-runner-osx.sh new file mode 100644 index 0000000000..a6da66116d --- /dev/null +++ b/modules/compute-providers/ec2/templates/start-runner-osx.sh @@ -0,0 +1,185 @@ +#!/bin/bash + +# macOS variant of start-runner.sh + +tag_instance_with_runner_id() { + echo "Checking for .runner file to extract agent ID" + + if [[ ! -f "/opt/actions-runner/.runner" ]]; then + echo "Warning: .runner file not found" + return 0 + fi + + echo "Found .runner file, extracting agent ID" + local agent_id + agent_id=$(jq -r '.agentId' /opt/actions-runner/.runner 2>/dev/null || echo "") + + if [[ -z "$agent_id" || "$agent_id" == "null" ]]; then + echo "Warning: Could not extract agent ID from .runner file" + return 0 + fi + + echo "Tagging instance with GitHub runner agent ID: $agent_id" + if aws ec2 create-tags \ + --region "$region" \ + --resources "$instance_id" \ + --tags Key=ghr:github_runner_id,Value="$agent_id"; then + echo "Successfully tagged instance with agent ID: $agent_id" + return 0 + else + echo "Warning: Failed to tag instance with agent ID" + return 0 + fi +} + +cleanup() { + local exit_code="$1" + + if [ "$exit_code" -ne 0 ]; then + echo "ERROR: runner-start-failed with exit code $exit_code" + fi + + if [ "$agent_mode" = "ephemeral" ] || [ "$exit_code" -ne 0 ]; then + echo "Terminating instance" + aws ec2 terminate-instances \ + --instance-ids "$instance_id" \ + --region "$region" || true + fi +} + +trap 'cleanup $?' EXIT + +echo "Retrieving TOKEN from AWS API" +token=$(curl -f -X PUT "http://169.254.169.254/latest/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 180" || true) +if [ -z "$token" ]; then + retrycount=0 + until [ -n "$token" ]; do + echo "Failed to retrieve token. Retrying in 5 seconds." + sleep 5 + token=$(curl -f -X PUT "http://169.254.169.254/latest/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 180" || true) + retrycount=$((retrycount + 1)) + if [ $retrycount -gt 40 ]; then + break + fi + done +fi + +region=$(curl -f -H "X-aws-ec2-metadata-token: $token" \ + http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region) +echo "Retrieved REGION from AWS API ($region)" + +instance_id=$(curl -f -H "X-aws-ec2-metadata-token: $token" \ + http://169.254.169.254/latest/meta-data/instance-id) +echo "Retrieved INSTANCE_ID from AWS API ($instance_id)" + +availability_zone=$(curl -f -H "X-aws-ec2-metadata-token: $token" \ + http://169.254.169.254/latest/meta-data/placement/availability-zone) + +environment=$(curl -f -H "X-aws-ec2-metadata-token: $token" \ + http://169.254.169.254/latest/meta-data/tags/instance/ghr:environment || echo "") +ssm_config_path=$(curl -f -H "X-aws-ec2-metadata-token: $token" \ + http://169.254.169.254/latest/meta-data/tags/instance/ghr:ssm_config_path || echo "") +runner_name_prefix=$(curl -f -H "X-aws-ec2-metadata-token: $token" \ + http://169.254.169.254/latest/meta-data/tags/instance/ghr:runner_name_prefix || echo "") + +echo "Retrieved ghr:environment tag - ($environment)" +echo "Retrieved ghr:ssm_config_path tag - ($ssm_config_path)" +echo "Retrieved ghr:runner_name_prefix tag - ($runner_name_prefix)" + +parameters=$(aws ssm get-parameters-by-path \ + --path "$ssm_config_path" \ + --region "$region" \ + --query "Parameters[*].{Name:Name,Value:Value}") +echo "Retrieved parameters from AWS SSM ($parameters)" + +run_as=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/run_as") | .Value') +echo "Retrieved /$ssm_config_path/run_as parameter - ($run_as)" + +agent_mode=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/agent_mode") | .Value') +echo "Retrieved /$ssm_config_path/agent_mode parameter - ($agent_mode)" + +disable_default_labels=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/disable_default_labels") | .Value') +echo "Retrieved /$ssm_config_path/disable_default_labels parameter - ($disable_default_labels)" + +enable_jit_config=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/enable_jit_config") | .Value') +echo "Retrieved /$ssm_config_path/enable_jit_config parameter - ($enable_jit_config)" + +token_path=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/token_path") | .Value') +echo "Retrieved /$ssm_config_path/token_path parameter - ($token_path)" + +echo "Get GH Runner config from AWS SSM" +config=$(aws ssm get-parameter --name "$token_path"/"$instance_id" --with-decryption --region "$region" | jq -r ".Parameter | .Value") +while [[ -z "$config" ]]; do + echo "Waiting for GH Runner config to become available in AWS SSM" + sleep 1 + config=$(aws ssm get-parameter --name "$token_path"/"$instance_id" --with-decryption --region "$region" | jq -r ".Parameter | .Value") +done + +echo "Delete GH Runner token from AWS SSM" +aws ssm delete-parameter --name "$token_path"/"$instance_id" --region "$region" + +if [ -z "$run_as" ]; then + echo "No user specified, using default ec2-user account" + run_as="ec2-user" +fi + +if [[ "$run_as" == "root" ]]; then + echo "run_as is set to root - export RUNNER_ALLOW_RUNASROOT=1" + export RUNNER_ALLOW_RUNASROOT=1 +fi + +sudo chown -R "$run_as" /opt/actions-runner + +info_arch=$(uname -m) +info_os=$(sw_vers -productName 2>/dev/null || echo "macOS") +info_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown") + +tee /opt/actions-runner/.setup_info <&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/compute-providers/ec2/templates/start-runner.sh b/modules/compute-providers/ec2/templates/start-runner.sh new file mode 100644 index 0000000000..7f2c0f82c5 --- /dev/null +++ b/modules/compute-providers/ec2/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/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/compute-providers/ec2/templates/user-data.sh b/modules/compute-providers/ec2/templates/user-data.sh new file mode 100644 index 0000000000..ca69f26d34 --- /dev/null +++ b/modules/compute-providers/ec2/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/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl new file mode 100644 index 0000000000..a6e50227c9 --- /dev/null +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -0,0 +1,410 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{}" + } + } + + mock_data "aws_ami" { + defaults = { + id = "ami-1234567890abcdef0" + name = "runner-test" + creation_date = "2026-01-01T00:00:00.000Z" + deprecation_time = "" + } + } + + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } +} + +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" + prefix = "provider-test" + + 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 = true + s3 = { + arn = "arn:aws:s3:::runner-distribution" + id = "runner-distribution" + key = "runner.zip" + } + } + cloudwatch_agent = { + enabled = true + } + ssm_enabled = true + managed_security_group_enabled = true + } + + 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" + } + } +} + +run "separates_control_plane_contract_from_ec2_resources" { + command = plan + + assert { + condition = output.provider.type == "ec2" + error_message = "The provider contract must identify EC2." + } + + assert { + 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.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.provider.policies.scale_up.iam_policy_json, "ec2:RunInstances") + error_message = "The EC2 provider must own EC2 scale-up permissions." + } + + assert { + 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.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.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.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.provider.policies.pool.managed_policy_enabled + 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 = 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.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 = toset(keys(output.provider.resources)) == toset(["launch_template", "runners_log_groups", "logfiles"]) + error_message = "EC2-specific artifacts must remain nested under provider resources." + } + + 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 { + 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" + } + } + } + + 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." + } + + 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" { + command = plan + + variables { + 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 = { + name_prefix = "required-prefix" + 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" + } + 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" + } + } + } + observability = { + logs = { + 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 + + variables { + config = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + binaries_syncer = { + enabled = true + s3 = null + } + } + } + + expect_failures = [var.config] +} diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf new file mode 100644 index 0000000000..49ce691faa --- /dev/null +++ b/modules/compute-providers/ec2/variables.tf @@ -0,0 +1,401 @@ +variable "aws_partition" { + description = "AWS partition used to construct IAM ARNs." + type = string + 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 + default = "github-actions" +} + +variable "tags" { + 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 "config" { + description = <<-EOT + 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. + EOT + + type = 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) + }) + + nullable = false + + validation { + condition = contains(["spot", "on-demand"], var.config.instance_target_capacity_type) + error_message = "config.instance_target_capacity_type must be spot or on-demand." + } + + validation { + 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." + } + + validation { + 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." + } + + 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." + } + + 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 "runner" { + description = <<-EOT + 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({ + 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) + }) + }) + + nullable = false + + validation { + condition = contains(["linux", "osx", "windows"], var.runner.os) + error_message = "runner.os must be linux, osx, or windows." + } + + validation { + condition = length(var.runner.name_prefix) <= 45 + error_message = "runner.name_prefix must be at most 45 characters." + } +} + +variable "github" { + description = <<-EOT + 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. + EOT + type = object({ + enterprise_server = optional(object({ + url = optional(string, null) + ssl_verify = optional(bool, true) + }), {}) + }) + default = {} + nullable = false +} + +variable "ssm" { + description = <<-EOT + 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. + EOT + type = object({ + paths = object({ + root = string + tokens = string + config = string + }) + tags = optional(map(string), {}) + parameters = optional(object({ + tags = optional(map(string), {}) + }), {}) + }) + + nullable = false +} + +variable "observability" { + description = <<-EOT + 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`. + EOT + 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/compute-providers/ec2/versions.tf b/modules/compute-providers/ec2/versions.tf new file mode 100644 index 0000000000..da9769f550 --- /dev/null +++ b/modules/compute-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/README.md b/modules/multi-runner/README.md index 06ca878634..9ace5c2855 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -18,6 +18,50 @@ The **webhook lambda** does not participate in round-robin: it only validates in 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 + +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. + +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. + +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. 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 as a module-level opt-in (current) + +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 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`. 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. + +#### 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: - 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. @@ -117,6 +161,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 | @@ -151,6 +196,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. 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 | @@ -175,7 +221,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 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 | | [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 | @@ -224,7 +269,8 @@ module "multi-runner" { | [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/multi-runner-config.tf b/modules/multi-runner/multi-runner-config.tf index abd0b7b624..f60340b6f2 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/multi-runner-config.tf @@ -1,101 +1,233 @@ 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 = { - 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 - 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 - 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 + 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 + tags = {} + 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 + } : null + managed_policy_arns = { + 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 + } + + 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 + 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 + 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 = { + 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 + tags = {} + lambda = { + memory_size = v.runner_config.job_retry.lambda_memory_size + timeout = v.runner_config.job_retry.lambda_timeout + reserved_concurrent_executions = 1 + } } - provider = { - type = "ec2" + ssm = { + tags = {} + kms_key = null + parameters = { + tags = {} + } + housekeeper = { + tags = {} + } + } + + observability = { + logs = { + tags = {} + } + } + + compute_provider = { 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 + # 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 + 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 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 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 } } - multi_runner_config = length(var.multi_runner_config_v2) > 0 ? var.multi_runner_config_v2 : local.multi_runner_config_v1_as_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 + + 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.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 = { 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.provider.type)) - runner = merge(v.runner, { 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 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 local.selected_multi_runner_config_v1 : 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 local.use_multi_runner_config_v2 + } + runner_matcher_config = { for k, v in local.runner_config : k => { id = v.id @@ -105,17 +237,19 @@ 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 : { - "os_type" : config.runner.runner_os, - "architecture" : config.runner.runner_architecture + for _, config in local.runner_config_by_provider.ec2 : { + "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/outputs.tf b/modules/multi-runner/outputs.tf index 50adb7fe46..1adb2e0ba4 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -1,5 +1,6 @@ output "runners_map" { + 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 @@ -21,6 +22,18 @@ output "runners_map" { } } +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" { value = { for runner_binary_key, runner_binary in module.runner_binaries : runner_binary_key => { lambda = runner_binary.lambda diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index 2b02010cd2..8923d139fc 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -42,7 +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 = var.tags + tags = local.sqs_tags[each.key] } resource "aws_sqs_queue_policy" "build_queue_policy" { @@ -58,7 +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 = 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 35a714dd80..410fb27969 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,16 +1,17 @@ module "runners" { - source = "../runners" - for_each = local.ec2_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) + 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.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.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}" @@ -18,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_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.runner_architecture - ami = each.value.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.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.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.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 + 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.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.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 @@ -68,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.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 @@ -80,33 +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.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.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.runner.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.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.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.provider.ec2.runner_ec2_tags + runner_ec2_tags = each.value.runner_config.runner_ec2_tags - create_service_linked_role_spot = each.value.provider.ec2.create_service_linked_role_spot + create_service_linked_role_spot = each.value.runner_config.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 + 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 @@ -116,15 +117,15 @@ module "runners" { log_level = var.log_level - pool_config = each.value.runner.pool_config + pool_config = each.value.runner_config.pool_config pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.runner.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 = each.value.runner.job_retry + job_retry = each.value.runner_config.job_retry metrics = var.metrics } 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..af0f8f586c --- /dev/null +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -0,0 +1,684 @@ +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\"}]}" + } + } +} + +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" + 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 + + variables { + tags = { + StableGlobal = "global" + Precedence = "global" + } + + 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"]] + } + redrive_build_queue = { + enabled = true + maxReceiveCount = 3 + } + } + } + } + + assert { + condition = keys(local.runner_config_by_provider.ec2) == ["linux"] + 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 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 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 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 { + condition = keys(output.runners_map) == ["linux"] + 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( + [ + "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_provider_stack" { + command = plan + + variables { + 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 = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + assert { + condition = keys(local.runner_config_by_provider.ec2) == ["linux"] + 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 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 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 runner configuration key." + } + + assert { + 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 = 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", + "scale_up", + "scale_down", + "pool", + ] + ) + error_message = "Experimental v2 runners_map_v2 entries must group common and provider resources by owner." + } + + assert { + condition = ( + 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_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_v2..provider.ec2." + } + + assert { + condition = ( + !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 only its nested schema through runners_map_v2 without legacy flat fields." + } + + assert { + 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." + } + + assert { + 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 "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 = { + 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"].scale_up.lambda.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"].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"].scale_up.role.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"].runner.role.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"].scale_down.lambda.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"].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." + } + + assert { + condition = output.runners_map_v2["tagged"].pool == null + error_message = "Experimental v2 must expose a null pool object when no pool configuration is supplied." + } +} + +run "experimental_v2_replaces_stable_v1" { + 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 = true + enable_organization_runners = true + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + experimental = { + multi_runner_config_v2 = { + experimental = { + runner = { + os = "linux" + architecture = "arm64" + maximum_count = 2 + } + github = { + organization_runners = true + } + compute_provider = { + ec2 = { + instance_types = ["m7g.large"] + binaries_syncer = { + enabled = true + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "arm64", "experimental"]] + } + } + } + } + } + + assert { + 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 = length(module.runners) == 0 && keys(module.runner_stacks) == ["experimental"] + error_message = "Selecting v2 must not create any legacy 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 v2 runner configuration keys when v2 is selected." + } + + assert { + condition = keys(module.runner_binaries) == ["linux_arm64"] + error_message = "Runner binary synchronization must ignore stable v1 configurations when v2 is selected." + } + + assert { + condition = ( + length(output.runners_map) == 0 + && keys(output.runners_map_v2) == ["experimental"] + ) + 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 = "The selected v2 configuration must retain its nested EC2 provider output." + } + + assert { + condition = ( + !contains(keys(output.runners_map_v2["experimental"]), "launch_template_name") + && !contains(keys(output.runners_map_v2["experimental"]), "lambda_up") + ) + error_message = "The v2 output must not contain fields from the legacy flat schema." + } +} + +run "experimental_v2_replaces_same_key_stable_v1" { + 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"]] + } + } + } + + experimental = { + multi_runner_config_v2 = { + duplicate = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "experimental"]] + } + } + } + } + } + + 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_empty_compute_provider" { + command = plan + + variables { + experimental = { + multi_runner_config_v2 = { + microvm = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = {} + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [var.experimental] +} + +run "experimental_v2_rejects_profile_without_role" { + command = plan + + variables { + experimental = { + multi_runner_config_v2 = { + invalid_profile = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + instance_profile = { + name = "external-profile" + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [var.experimental] +} diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index a0ee5dd137..6628c249c7 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -1,209 +1,420 @@ -variable "multi_runner_config_v2" { - description = < This module is treated as an internal module; breaking changes do not trigger a major release bump. + +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 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`. 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 + +`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `scale_up`, `scale_down`, `pool`, `job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes. + +Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `scale_up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `scale_up.tags`. + +Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. + +## 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 `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.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 | +| [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | +| [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | + +## 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 | +| [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.runner_assume_role](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.

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 | +| [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 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 | +|------|-------------| +| [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. | +| [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/common-config.tf b/modules/runner-stack/common-config.tf new file mode 100644 index 0000000000..bbbe12b65f --- /dev/null +++ b/modules/runner-stack/common-config.tf @@ -0,0 +1,49 @@ +# Shared control-plane configuration: naming, paths, tags, and normalized values. +locals { + 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 = 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 local.ssm_parameter_tags : { + Key = key + Value = value + } + ]) +} + +data "aws_caller_identity" "current" {} 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 new file mode 100644 index 0000000000..ba734bf3f8 --- /dev/null +++ b/modules/runner-stack/ec2.tf @@ -0,0 +1,19 @@ +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 = var.compute_provider.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/job-retry.tf b/modules/runner-stack/job-retry.tf new file mode 100644 index 0000000000..2631f1c279 --- /dev/null +++ b/modules/runner-stack/job-retry.tf @@ -0,0 +1,62 @@ + +locals { + job_retry_enabled = var.job_retry.enabled +} + +module "job_retry" { + source = "./job-retry" + count = local.job_retry_enabled ? 1 : 0 + + 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 new file mode 100644 index 0000000000..ffba2f9636 --- /dev/null +++ b/modules/runner-stack/job-retry/README.md @@ -0,0 +1,61 @@ +# 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 + +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) | 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/job-retry/iam-policies.tf b/modules/runner-stack/job-retry/iam-policies.tf new file mode 100644 index 0000000000..6f0a3b215e --- /dev/null +++ b/modules/runner-stack/job-retry/iam-policies.tf @@ -0,0 +1,104 @@ +# 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.lambda.role.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.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" "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.queue.build.arn] + } + + dynamic "statement" { + for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] + + content { + effect = "Allow" + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [statement.value.arn] + } + } +} diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/runner-stack/job-retry/job-retry.tf new file mode 100644 index 0000000000..a8e873ed3c --- /dev/null +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -0,0 +1,177 @@ +# Provider-neutral job-retry queue and Lambda resources. +locals { + 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 = var.config.observability.logs.level + PREFIX = var.config.prefix + POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" + POWERTOOLS_SERVICE_NAME = local.name + 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.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.lambda.environment_variables, + local.job_retry_environment_variables, + ) +} + +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 = 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 + + tags = var.config.tags.queue +} + +resource "aws_lambda_function" "job_retry" { + 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 = 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 + } + + 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 + } + } + + 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 = 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 = 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" { + 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:${var.config.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "job_retry_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.job_retry.name +} + +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.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" { + statement_id = "AllowExecutionFromSQS" + action = "lambda:InvokeFunction" + 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 = aws_iam_role.job_retry.name + policy = data.aws_iam_policy_document.job_retry.json +} + +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..4f08cc4498 --- /dev/null +++ b/modules/runner-stack/job-retry/outputs.tf @@ -0,0 +1,13 @@ +output "lambda" { + description = "Job-retry Lambda resources." + value = { + 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" { + 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 new file mode 100644 index 0000000000..40cd279e30 --- /dev/null +++ b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl @@ -0,0 +1,260 @@ +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" + 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"] + }] + } + } + runner = { + name_prefix = "required-prefix-" + } + github = { + organization_runners = false + enterprise_server = { + url = "" + } + 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" + } + } + } + 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 = { + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/job-retry-test" + } + } + 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_nested_job_retry_configuration" { + command = plan + + assert { + 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.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" + && output.lambda.function.reserved_concurrent_executions == 1 + ) + error_message = "The nested Lambda configuration and direct resource output contract must be preserved." + } + + assert { + 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 = ( + 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 = "Logging, KMS, complete VPC, tracing, and extra role-principal configuration must be preserved." + } +} + +run "does_not_enable_partial_vpc_configuration" { + command = plan + + variables { + config = { + 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 = {} + vpc = { + security_group_ids = [] + subnet_ids = ["subnet-12345678"] + } + role = { + path = "/job-retry-test/" + principals = [] + } + } + 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 = {} + } + } + } + + 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 new file mode 100644 index 0000000000..950bd6eb8b --- /dev/null +++ b/modules/runner-stack/job-retry/variables.tf @@ -0,0 +1,168 @@ +variable "config" { + 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 + + type = 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) + }) + }) + + nullable = false + + validation { + condition = contains(["arm64", "x86_64"], var.config.lambda.architecture) + error_message = "config.lambda.architecture must be arm64 or x86_64." + } + + validation { + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "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 = 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/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/outputs.tf b/modules/runner-stack/outputs.tf new file mode 100644 index 0000000000..f554ad19f6 --- /dev/null +++ b/modules/runner-stack/outputs.tf @@ -0,0 +1,29 @@ +output "runner" { + description = "Common runner resources. The role is null when an external runner role is used." + value = { + role = one(aws_iam_role.runner[*]) + } +} + +output "scale_up" { + description = "Scale-up control-plane resources." + value = module.scale_runners.scale_up +} + +output "scale_down" { + description = "Scale-down control-plane resources." + value = module.scale_runners.scale_down +} + +output "pool" { + description = "Scheduled pool resources. Null when no pool configuration is supplied." + value = one(module.pool[*].pool) +} + +output "provider" { + description = "Selected compute provider type and its provider-specific resources." + value = { + type = local.provider.type + ec2 = local.provider.resources + } +} diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf new file mode 100644 index 0000000000..bc4a3ce25d --- /dev/null +++ b/modules/runner-stack/pool.tf @@ -0,0 +1,64 @@ +module "pool" { + count = length(var.pool.config) == 0 ? 0 : 1 + + source = "./pool" + + config = { + prefix = var.prefix + ghes = { + ssl_verify = var.github.enterprise_server.ssl_verify + url = var.github.enterprise_server.url + } + user_agent = var.github.user_agent + github_app_parameters = var.github.app_parameters + runners_maximum_count = var.runner.maximum_count + kms_key = local.kms_key + lambda = { + 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 + 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.lambda_role_path + role_permissions_boundary = var.lambda.role.permissions_boundary + runner = { + 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}" + 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 + } + + aws_partition = var.aws_partition + tracing_config = var.observability.tracing + runner_provider = { + type = local.provider.type + 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/pool/README.md b/modules/runner-stack/pool/README.md new file mode 100644 index 0000000000..64553579ff --- /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.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 | + +## 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 | +| [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 | +|------|-------------| +| [pool](#output\_pool) | Scheduled pool Lambda resources. | + diff --git a/modules/runner-stack/pool/iam-policies.tf b/modules/runner-stack/pool/iam-policies.tf new file mode 100644 index 0000000000..13690cce09 --- /dev/null +++ b/modules/runner-stack/pool/iam-policies.tf @@ -0,0 +1,66 @@ +# IAM policies attached to the pool Lambda role. +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 == null ? [] : [var.config.kms_key] + + content { + effect = "Allow" + + actions = ["kms:Decrypt"] + resources = [statement.value.arn] + } + } +} + +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/outputs.tf b/modules/runner-stack/pool/outputs.tf new file mode 100644 index 0000000000..cfc429ecce --- /dev/null +++ b/modules/runner-stack/pool/outputs.tf @@ -0,0 +1,8 @@ +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/pool.tf b/modules/runner-stack/pool/pool.tf new file mode 100644 index 0000000000..5e95c897ca --- /dev/null +++ b/modules/runner-stack/pool/pool.tf @@ -0,0 +1,225 @@ +# Provider-neutral pool Lambda and scheduler wiring. +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 = merge(var.config.tags, var.config.log_group_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 = [ + 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 = data.aws_iam_policy_document.pool_logging.json +} + +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/tests/provider.tftest.hcl b/modules/runner-stack/pool/tests/provider.tftest.hcl new file mode 100644 index 0000000000..b352a03c26 --- /dev/null +++ b/modules/runner-stack/pool/tests/provider.tftest.hcl @@ -0,0 +1,135 @@ +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 = "arn:aws:kms:eu-west-1:123456789012:key/pool-test" + } + 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 = 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." + } + + 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(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 new file mode 100644 index 0000000000..63cbf90e30 --- /dev/null +++ b/modules/runner-stack/pool/variables.tf @@ -0,0 +1,172 @@ +variable "config" { + 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 + 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 + }) +} + +variable "runner_provider" { + 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) + 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 = <<-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) + 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-role.tf b/modules/runner-stack/runner-role.tf new file mode 100644 index 0000000000..588cdcfa76 --- /dev/null +++ b/modules/runner-stack/runner-role.tf @@ -0,0 +1,70 @@ +locals { + # 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 + 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 : + "user-${policy_name}" => policy_arn + }, + var.observability.tracing.mode != null ? { + xray = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" + } : {}, + { + 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 = 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(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_policies.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/runner-ssm-parameters.tf b/modules/runner-stack/runner-ssm-parameters.tf new file mode 100644 index 0000000000..43708f1d02 --- /dev/null +++ b/modules/runner-stack/runner-ssm-parameters.tf @@ -0,0 +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" + type = "String" + value = var.runner.ephemeral ? "ephemeral" : "persistent" + 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.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.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.ssm_parameter_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-runners.tf b/modules/runner-stack/scale-runners.tf new file mode 100644 index 0000000000..bf28050faf --- /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.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.environment_variables.scale_down + iam_policy_json = local.provider.policies.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..861792c99a --- /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/scale-runners/lambda-iam-policies.tf b/modules/runner-stack/scale-runners/lambda-iam-policies.tf new file mode 100644 index 0000000000..05922c734e --- /dev/null +++ b/modules/runner-stack/scale-runners/lambda-iam-policies.tf @@ -0,0 +1,26 @@ +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 = ["*"] + } +} 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-runners/scale-down-iam-policies.tf b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf new file mode 100644 index 0000000000..c61e8dc68b --- /dev/null +++ b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf @@ -0,0 +1,41 @@ +data "aws_iam_policy_document" "scale_down_common" { + 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.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] + + content { + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [statement.value.arn] + } + } +} + +data "aws_iam_policy_document" "scale_down" { + source_policy_documents = [ + data.aws_iam_policy_document.scale_down_common.json, + var.runner_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-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-runners/scale-up-iam-policies.tf b/modules/runner-stack/scale-runners/scale-up-iam-policies.tf new file mode 100644 index 0000000000..2e64d54876 --- /dev/null +++ b/modules/runner-stack/scale-runners/scale-up-iam-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.config.github.app_parameters.key_base64.arn, + var.config.github.app_parameters.id.arn, + "${var.config.ssm.config_path_arn}/*", + ] + } + + statement { + effect = "Allow" + actions = [ + "sqs:ReceiveMessage", + "sqs:GetQueueAttributes", + "sqs:DeleteMessage", + ] + resources = [var.config.queue.build.arn] + } + + dynamic "statement" { + for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] + + content { + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [statement.value.arn] + } + } +} + +data "aws_iam_policy_document" "scale_up" { + source_policy_documents = [ + data.aws_iam_policy_document.scale_up_common.json, + var.runner_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 = var.config.job_retry.enabled ? 1 : 0 + + statement { + effect = "Allow" + actions = [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + ] + 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/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper.tf new file mode 100644 index 0000000000..18912392d4 --- /dev/null +++ b/modules/runner-stack/ssm-housekeeper.tf @@ -0,0 +1,57 @@ +locals { + 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}*" + ) +} + +module "ssm_housekeeper" { + source = "./ssm-housekeeper" + + config = { + prefix = var.prefix + aws_partition = var.aws_partition + schedule = { + expression = var.ssm.housekeeper.schedule_expression + state = var.ssm.housekeeper.state + } + 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 + } + 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 + } + } +} diff --git a/modules/runner-stack/ssm-housekeeper/README.md b/modules/runner-stack/ssm-housekeeper/README.md new file mode 100644 index 0000000000..4cc0af9d63 --- /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/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/computed-iam-inputs.tftest.hcl b/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl new file mode 100644 index 0000000000..9e81f63b64 --- /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_external_values_keep_plan_shape_known" { + command = plan + + module { + source = "./tests/fixtures/computed-iam-inputs" + } + + assert { + condition = output.external_role_runner_count == 0 + error_message = "Computed external AMI parameter, KMS key, role, and profile values must not make resource or policy-block 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/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 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..20bcdbef52 --- /dev/null +++ b/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -0,0 +1,177 @@ +# 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 +} + +resource "random_id" "generated_policy" { + byte_length = 4 +} + +module "external_iam" { + source = "../../.." + + aws_region = "eu-west-1" + prefix = "computed-external" + + compute_provider = { + ec2 = { + 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}" + } + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } + } + } + + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external-runner-${random_id.external.hex}" + } + } + } + + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-external" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-external" + } + } + + lambda = { + s3 = { + bucket = "lambda-artifacts" + key = "runners.zip" + } + } + + job_retry = { + enabled = true + } + + pool = { + runner_owner = "example" + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + + 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 = { + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" + } + paths = { + root = "/github-runner/computed-external" + tokens = "tokens" + config = "config" + } + } +} + +module "generated_policy" { + source = "../../.." + + aws_region = "eu-west-1" + prefix = "computed-policy" + + compute_provider = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } + } + } + + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + managed_policy_arns = { + generated = "arn:aws:iam::123456789012:policy/generated-runner-${random_id.generated_policy.hex}" + } + } + } + + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-policy" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-policy" + } + } + + lambda = { + s3 = { + bucket = "lambda-artifacts" + key = "runners.zip" + } + } + + 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" + } + } +} + +output "external_role_runner_count" { + value = module.external_iam.runner.role == null ? 0 : 1 +} + +output "generated_policy_role_runner_count" { + value = module.generated_policy.runner.role == null ? 0 : 1 +} 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 new file mode 100644 index 0000000000..c9c775ea27 --- /dev/null +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -0,0 +1,361 @@ +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" + + compute_provider = { + 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" + } + } + ssm_enabled = 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" + key = "runners.zip" + } + } + + 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" + } + } + + # 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 = 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 = 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 + && 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(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." + } + + 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 = 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 = 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 = 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 = ( + 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." + } + +} + +run "external_runner_role_is_not_managed_by_common" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + 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 = output.runner.role == null + error_message = "The nested runner role output must be null 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 = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-profile" + } + binaries_syncer = { + enabled = 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 = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-profile" + } + binaries_syncer = { + enabled = false + } + } + } + } + + expect_failures = [aws_iam_role.runner] +} + +run "empty_runner_iam_uses_common_role" { + command = plan + + variables { + 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." + } +} + +run "external_role_rejects_managed_policy_attachments" { + command = plan + + variables { + 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] +} + +run "requires_distribution_object_when_sync_is_enabled" { + command = plan + + variables { + compute_provider = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + binaries_syncer = { + enabled = true + s3 = null + } + } + } + } + + expect_failures = [var.compute_provider] +} + +run "rejects_empty_compute_provider" { + command = plan + + variables { + compute_provider = {} + } + + expect_failures = [var.compute_provider] +} + +run "job_retry_uses_common_runner_configuration_identity" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + name_prefix = "provider-neutral-" + } + job_retry = { + enabled = true + lambda = { + reserved_concurrent_executions = 2 + } + } + } + + assert { + 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.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 new file mode 100644 index 0000000000..006c57ea97 --- /dev/null +++ b/modules/runner-stack/tests/tags.tftest.hcl @@ -0,0 +1,301 @@ +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 = { + 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 = 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 = module.scale_runners.scale_up.lambda.tags == tomap({ + precedence = "scale-up" + module = "yes" + lambda = "yes" + scale_up = "yes" + }) && module.scale_runners.scale_up.log_group.tags == tomap({ + precedence = "scale-up" + module = "yes" + log = "yes" + scale_up = "yes" + }) && module.scale_runners.scale_up.role.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 = module.scale_runners.scale_down.lambda.tags == tomap({ + precedence = "scale-down" + module = "yes" + lambda = "yes" + scale_down = "yes" + }) && module.scale_runners.scale_down.log_group.tags == tomap({ + precedence = "scale-down" + module = "yes" + log = "yes" + scale_down = "yes" + }) && module.scale_runners.scale_down.role.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(module.scale_runners.scale_up.lambda.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 = module.ssm_housekeeper.housekeeper.lambda.tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + lambda = "yes" + ssm = "yes" + housekeeper = "yes" + }) && module.ssm_housekeeper.housekeeper.log_group.tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + log = "yes" + ssm = "yes" + housekeeper = "yes" + }) && module.ssm_housekeeper.housekeeper.role.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].pool.lambda.tags == tomap({ + precedence = "pool" + module = "yes" + lambda = "yes" + pool = "yes" + }) && module.pool[0].pool.log_group.tags == tomap({ + precedence = "pool" + module = "yes" + log = "yes" + pool = "yes" + }) && module.pool[0].pool.role.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.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.compute-provider.tf b/modules/runner-stack/variables.compute-provider.tf new file mode 100644 index 0000000000..3d0c8da129 --- /dev/null +++ b/modules/runner-stack/variables.compute-provider.tf @@ -0,0 +1,298 @@ +# 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. + + 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. + EOT + + type = 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) + }) + + validation { + 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 { + 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.binaries_syncer.enabled || var.compute_provider.ec2.binaries_syncer.s3 != null + ) + 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/variables.tf b/modules/runner-stack/variables.tf new file mode 100644 index 0000000000..cfb2257a5b --- /dev/null +++ b/modules/runner-stack/variables.tf @@ -0,0 +1,429 @@ +variable "aws_region" { + description = "AWS region." + type = string +} + +variable "aws_partition" { + description = "AWS partition used to construct ARNs." + type = string + default = "aws" +} + +variable "prefix" { + description = "The prefix used for naming resources." + type = string + default = "github-actions" +} + +variable "tags" { + 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 = <<-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") + 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) + }), {}) + }) + + validation { + condition = contains(["linux", "osx", "windows"], var.runner.os) + error_message = "Valid values for runner.os are linux, osx, and windows." + } + + validation { + condition = length(var.runner.name_prefix) <= 45 + error_message = "runner.name_prefix must be at most 45 characters." + } + + 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." + } +} + +variable "github" { + 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) + 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 "queue" { + 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 + url = string + }) + event_source_mapping = optional(object({ + batch_size = optional(number, 10) + maximum_batching_window_in_seconds = optional(number, 0) + }), {}) + tags = optional(map(string), {}) + }) + + 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." + } + + validation { + 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 "lambda" { + 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({ + 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 = {} + + validation { + condition = contains(["arm64", "x86_64"], var.lambda.architecture) + error_message = "lambda.architecture must be arm64 or x86_64." + } +} + +variable "scale_up" { + 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 = <<-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 + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), []) + }) + default = {} +} + +variable "pool" { + 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 + 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) + }), {}) + }) + default = {} +} + +variable "job_retry" { + 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) + timeout = optional(number, 30) + }), {}) + }) + default = {} + + validation { + condition = !var.job_retry.enabled || var.job_retry.delay_in_seconds <= 900 + error_message = "job_retry.delay_in_seconds cannot exceed the SQS maximum of 900 seconds." + } +} + +variable "ssm" { + 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 = 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) + }), {}) + }), {}) + }) +} + +variable "observability" { + 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({ + 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) + }), {}) + }), {}) + }) + default = {} + + validation { + condition = contains(["STANDARD", "INFREQUENT_ACCESS"], var.observability.logs.class) + error_message = "observability.logs.class must be STANDARD or INFREQUENT_ACCESS." + } + + validation { + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ], var.observability.logs.level) + error_message = "observability.logs.level must be one of silly, trace, debug, info, warn, error, or fatal." + } +} diff --git a/modules/runner-stack/versions.tf b/modules/runner-stack/versions.tf new file mode 100644 index 0000000000..da9769f550 --- /dev/null +++ b/modules/runner-stack/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} From c9d018ea01d546ea4dd22af5f758de79830b2fbb Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 21:52:26 +0200 Subject: [PATCH 04/49] refactor(multi-runner): preserve legacy runner path --- modules/multi-runner/compute-provider.tf | 24 ++++++ ...unner-config.tf => config.experimental.tf} | 77 ------------------- modules/multi-runner/main.tf | 11 +++ modules/multi-runner/queues.tf | 9 +++ modules/multi-runner/runners.experimental.tf | 13 ++++ modules/multi-runner/runners.tf | 5 +- .../tests/provider-routing.tftest.hcl | 31 +++++--- modules/multi-runner/webhook.tf | 11 +++ 8 files changed, 92 insertions(+), 89 deletions(-) create mode 100644 modules/multi-runner/compute-provider.tf rename modules/multi-runner/{multi-runner-config.tf => config.experimental.tf} (77%) diff --git a/modules/multi-runner/compute-provider.tf b/modules/multi-runner/compute-provider.tf new file mode 100644 index 0000000000..f996ed0014 --- /dev/null +++ b/modules/multi-runner/compute-provider.tf @@ -0,0 +1,24 @@ +locals { + compute_provider_types = { + for runner_key, runner_config in local.multi_runner_config : runner_key => one([ + for provider_type, provider_config in runner_config.compute_provider : provider_type + if provider_config != null + ]) + } + + runner_config_by_provider = { + ec2 = { + for runner_key, runner_config in local.multi_runner_config : runner_key => runner_config + if local.compute_provider_types[runner_key] == "ec2" + } + } + + tmp_distinct_list_unique_os_and_arch = distinct([ + for _, config in local.runner_config_by_provider.ec2 : { + "os_type" : config.runner.os, + "architecture" : config.runner.architecture + } + 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/multi-runner-config.tf b/modules/multi-runner/config.experimental.tf similarity index 77% rename from modules/multi-runner/multi-runner-config.tf rename to modules/multi-runner/config.experimental.tf index f60340b6f2..38720b9c51 100644 --- a/modules/multi-runner/multi-runner-config.tf +++ b/modules/multi-runner/config.experimental.tf @@ -175,81 +175,4 @@ locals { # 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 - - 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))) - } - - 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 = 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 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 local.selected_multi_runner_config_v1 : 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 local.use_multi_runner_config_v2 - } - - runner_matcher_config = { - for k, v in local.runner_config : k => { - id = v.id - arn = v.arn - runnerProvider = v.runnerProvider - matcherConfig = v.matcherConfig - } - } - - 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.runner_config_by_provider.ec2 : { - "os_type" : config.runner.os, - "architecture" : config.runner.architecture - } - 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/main.tf b/modules/multi-runner/main.tf index 9d1274cb16..9bfcb13ed0 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -22,6 +22,17 @@ locals { webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) } + runner_extra_labels = { 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 = { 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 + url = aws_sqs_queue.queued_builds[k].url + }, + merge(v, { runner_config = merge(v.runner_config, { runner_extra_labels = local.runner_extra_labels[k] }) }), + ) } + ssm_root_path = "/${var.ssm_paths.root}/${var.prefix}" } diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index 8923d139fc..615cd1187d 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -1,3 +1,12 @@ +locals { + sqs_tags = { + for k, v in local.multi_runner_config : k => merge( + var.tags, + v.tags, + v.queue.tags, + ) + } +} data "aws_iam_policy_document" "deny_insecure_transport" { statement { diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index f230166fb1..51a881f80b 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -1,3 +1,16 @@ +locals { + runner_config_v2 = { + for k, v in local.selected_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 + runner = merge(v.runner, { + extra_labels = sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.extra_labels))) + }) + }) + } +} + module "runner_stacks" { source = "../runner-stack" for_each = local.runner_config_v2 diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 410fb27969..892113dcc7 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,7 +1,6 @@ module "runners" { - source = "../runners" - for_each = local.runner_config_v1 - + source = "../runners" + for_each = local.runner_config aws_region = var.aws_region aws_partition = var.aws_partition vpc_id = coalesce(each.value.runner_config.vpc_id, var.vpc_id) diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index af0f8f586c..fa6f793b8b 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -77,17 +77,17 @@ run "stable_v1_keeps_legacy_runner_module" { } assert { - condition = keys(local.runner_config_v1) == ["linux"] && length(local.runner_config_v2) == 0 - error_message = "Stable multi_runner_config entries must remain isolated in the v1 configuration map." + condition = keys(local.runner_config) == ["linux"] && length(local.runner_config_v2) == 0 + error_message = "Stable multi_runner_config entries must keep the original runner configuration and remain isolated from v2." } 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 + contains(keys(local.runner_config["linux"]), "runner_config") + && !contains(keys(local.runner_config["linux"]), "compute_provider") + && local.runner_config["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." + error_message = "Stable module inputs must retain the original local.runner_config shape instead of using the v1-to-v2 translation." } assert { @@ -201,10 +201,23 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = length(local.runner_config_v1) == 0 && keys(local.runner_config_v2) == ["linux"] + condition = ( + local.compute_provider_types["linux"] == "ec2" + && local.runner_matcher_config["linux"].runnerProvider == "ec2" + ) + error_message = "Compute-provider selection must supply the webhook routing contract." + } + + assert { + condition = length(local.runner_config) == 0 && keys(local.runner_config_v2) == ["linux"] error_message = "Experimental multi_runner_config_v2 entries must remain isolated in the v2 configuration map." } + assert { + condition = toset(local.runner_config_v2["linux"].runner.extra_labels) == toset(["self-hosted", "linux", "x64"]) + error_message = "Experimental runner labels must include labels declared by its matcher configuration." + } + assert { condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["linux"] error_message = "Experimental multi_runner_config_v2 entries must dispatch through module.runner_stacks." @@ -520,7 +533,7 @@ run "experimental_v2_replaces_stable_v1" { } assert { - condition = length(local.runner_config_v1) == 0 && keys(local.runner_config_v2) == ["experimental"] + condition = length(local.runner_config) == 0 && keys(local.runner_config_v2) == ["experimental"] error_message = "A non-empty experimental configuration must select only the v2 configuration map." } @@ -609,7 +622,7 @@ run "experimental_v2_replaces_same_key_stable_v1" { assert { condition = ( - length(local.runner_config_v1) == 0 + length(local.runner_config) == 0 && keys(local.runner_config_v2) == ["duplicate"] && length(module.runners) == 0 && keys(module.runner_stacks) == ["duplicate"] diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index f7de9c8184..2e829d6a0b 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -1,3 +1,14 @@ +locals { + runner_matcher_config = { + for k, v in local.multi_runner_config : k => { + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + runnerProvider = local.compute_provider_types[k] + matcherConfig = v.matcherConfig + } + } +} + module "webhook" { source = "../webhook" prefix = var.prefix From 5b8d2c55fe354913582012dcc6edacf56af580fe Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 22:25:32 +0200 Subject: [PATCH 05/49] refactor(multi-runner): isolate v1 and v2 provider routing --- docs/index.md | 2 +- .../internal/compute-provider-refactor.md | 16 +- modules/compute-providers/ec2/outputs.tf | 1 - .../ec2/tests/provider.tftest.hcl | 4 +- modules/multi-runner/README.md | 10 +- modules/multi-runner/compute-provider.tf | 9 - modules/multi-runner/main.tf | 13 +- .../tests/provider-routing.tftest.hcl | 158 +----------------- .../multi-runner/variables.experimental.tf | 2 +- modules/runner-stack/README.md | 4 +- modules/runner-stack/outputs.tf | 5 +- modules/runner-stack/pool.tf | 2 +- modules/runner-stack/scale-runners.tf | 2 +- modules/runner-stack/tests/pool.tftest.hcl | 4 +- 14 files changed, 37 insertions(+), 195 deletions(-) diff --git a/docs/index.md b/docs/index.md index f54cc07eb5..ae2713ff48 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`. 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. +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 exposes both contracts but requires callers to populate only one runner configuration map per module instance; 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 b3615217cd..9a8ca49884 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 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. + The provider-oriented Terraform interface is experimental. Its schema can change before it becomes stable. To enable it for the whole module instance, leave `multi_runner_config` empty and populate `experimental.multi_runner_config_v2`. When the v2 map is empty, existing `multi_runner_config` deployments continue to use the unchanged legacy implementation. Populating both maps is unsupported. ## Why this refactor exists @@ -43,14 +43,14 @@ Returning the runner trust policy from the same resource-bearing provider module ## Phase 1 dispatch and compatibility -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. +Phase 1 exposes both contracts but requires callers to populate only one runner configuration map per module instance. An empty `experimental.multi_runner_config_v2` selects the stable v1 path. To select the experimental v2 path, `multi_runner_config` must be empty and the v2 map must be populated. The maps are never merged, and supplying both is unsupported. ```mermaid flowchart TD 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"] + Select -->|Yes, with v1 empty| V2["Select v2"] V1 --> Shared["Queues, webhook matching, binary discovery"] V2 --> Shared V1 --> Legacy["module.runners[configuration]"] @@ -69,9 +69,9 @@ The selected input is normalized once so shared resources can consume one repres - 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. -- 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. +- When `multi_runner_config` is empty and `experimental.multi_runner_config_v2` is non-empty, every key in the v2 map calls `modules/runner-stack` at `module.runner_stacks["configuration"]`. - Experimental resources are exposed separately through the nested `runners_map_v2` output. -- The maps are not combined and duplicate keys do not need special precedence: v2 is the complete selected configuration whenever it is non-empty. +- The maps are not combined, and there is no precedence rule between them. Populating both maps is unsupported. 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. @@ -83,8 +83,8 @@ Set the complete runner configuration map inside the nested experimental object module "multi_runner" { source = "github-aws-runners/github-runner/aws//modules/multi-runner" - # 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. + # A non-empty v2 map is the module-level experimental opt-in. Leave + # multi_runner_config empty when using it. experimental = { multi_runner_config_v2 = { arm = { @@ -117,7 +117,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. -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. +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 populated provider key identifies the compute provider without a duplicate type field. The `pool` value is null when no pool configuration is supplied. ## Plan-time provider selection and ownership wrappers diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf index 558a8b9610..aaab987746 100644 --- a/modules/compute-providers/ec2/outputs.tf +++ b/modules/compute-providers/ec2/outputs.tf @@ -1,7 +1,6 @@ output "provider" { description = "Nested EC2 compute-provider contract consumed by runner-stack." value = { - type = "ec2" environment_variables = { scale_up = local.scale_up_environment_variables scale_down = local.scale_down_environment_variables diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl index a6e50227c9..fb504fb9c9 100644 --- a/modules/compute-providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -88,8 +88,8 @@ run "separates_control_plane_contract_from_ec2_resources" { command = plan assert { - condition = output.provider.type == "ec2" - error_message = "The provider contract must identify EC2." + condition = toset(keys(output.provider)) == toset(["environment_variables", "policies", "resources"]) + error_message = "The EC2 provider contract must expose only integration and resource data; its module identity must not be repeated in the output." } assert { diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 9ace5c2855..07ef64ee4b 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -24,17 +24,17 @@ 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. -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. +To opt into v2, leave `multi_runner_config` empty and populate `experimental.multi_runner_config_v2`. The whole module instance then uses `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 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. -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. +Phase 1 supports both input contracts, but callers must populate only one runner configuration map per module instance. When `experimental.multi_runner_config_v2` is empty, `multi_runner_config` follows the unchanged legacy path. To use v2, `multi_runner_config` must be empty and `experimental.multi_runner_config_v2` becomes the complete runner map. The maps are not merged, and populating both is unsupported. ### 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. 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. +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 populated provider key identifies the compute provider without a duplicate type field. ### Multi-runner v2 migration roadmap @@ -42,7 +42,7 @@ Here, v1 and v2 refer to `multi_runner_config` and `experimental.multi_runner_co #### Phase 1 — Add v2 as a module-level opt-in (current) -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. +Both input contracts are available in the same module release, but callers must populate only one 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. To select `module.runner_stacks["configuration"]` and the nested `runners_map_v2` output shape, leave `multi_runner_config` empty and populate the v2 map. Populating both maps is unsupported. 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. @@ -196,7 +196,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. 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 | +| [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. To opt into v2, leave `multi_runner_config` empty and populate this map. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module. Populating both maps in the same module instance is unsupported.

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/compute-provider.tf b/modules/multi-runner/compute-provider.tf index f996ed0014..888c9f066a 100644 --- a/modules/multi-runner/compute-provider.tf +++ b/modules/multi-runner/compute-provider.tf @@ -12,13 +12,4 @@ locals { if local.compute_provider_types[runner_key] == "ec2" } } - - tmp_distinct_list_unique_os_and_arch = distinct([ - for _, config in local.runner_config_by_provider.ec2 : { - "os_type" : config.runner.os, - "architecture" : config.runner.architecture - } - 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/main.tf b/modules/multi-runner/main.tf index 9bfcb13ed0..43dcf51065 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -22,9 +22,9 @@ locals { webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) } - runner_extra_labels = { 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_extra_labels = { for k, v in var.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner_config.runner_extra_labels))) } - runner_config = { for k, v in local.selected_multi_runner_config_v1 : k => merge( + runner_config = { 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 @@ -33,6 +33,15 @@ locals { merge(v, { runner_config = merge(v.runner_config, { runner_extra_labels = local.runner_extra_labels[k] }) }), ) } + tmp_distinct_list_unique_os_and_arch = distinct([ + for _, config in local.runner_config_by_provider.ec2 : { + "os_type" : config.runner.os, + "architecture" : config.runner.architecture + } + 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 } + ssm_root_path = "/${var.ssm_paths.root}/${var.prefix}" } diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index fa6f793b8b..2e12fd097d 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -263,7 +263,7 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - output.runners_map_v2["linux"].provider.type == "ec2" + toset(keys(output.runners_map_v2["linux"].provider)) == toset(["ec2"]) && toset(keys(output.runners_map_v2["linux"].provider.ec2)) == toset([ "launch_template", "runners_log_groups", @@ -485,162 +485,6 @@ run "experimental_v2_layers_shared_and_component_tags" { } } -run "experimental_v2_replaces_stable_v1" { - 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 = true - enable_organization_runners = true - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } - } - } - - experimental = { - multi_runner_config_v2 = { - experimental = { - runner = { - os = "linux" - architecture = "arm64" - maximum_count = 2 - } - github = { - organization_runners = true - } - compute_provider = { - ec2 = { - instance_types = ["m7g.large"] - binaries_syncer = { - enabled = true - } - } - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "arm64", "experimental"]] - } - } - } - } - } - - assert { - condition = length(local.runner_config) == 0 && keys(local.runner_config_v2) == ["experimental"] - error_message = "A non-empty experimental configuration must select only the v2 configuration map." - } - - assert { - condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["experimental"] - error_message = "Selecting v2 must not create any legacy 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 v2 runner configuration keys when v2 is selected." - } - - assert { - condition = keys(module.runner_binaries) == ["linux_arm64"] - error_message = "Runner binary synchronization must ignore stable v1 configurations when v2 is selected." - } - - assert { - condition = ( - length(output.runners_map) == 0 - && keys(output.runners_map_v2) == ["experimental"] - ) - 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 = "The selected v2 configuration must retain its nested EC2 provider output." - } - - assert { - condition = ( - !contains(keys(output.runners_map_v2["experimental"]), "launch_template_name") - && !contains(keys(output.runners_map_v2["experimental"]), "lambda_up") - ) - error_message = "The v2 output must not contain fields from the legacy flat schema." - } -} - -run "experimental_v2_replaces_same_key_stable_v1" { - 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"]] - } - } - } - - experimental = { - multi_runner_config_v2 = { - duplicate = { - runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - } - compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false - } - } - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "experimental"]] - } - } - } - } - } - - assert { - condition = ( - length(local.runner_config) == 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_empty_compute_provider" { command = plan diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index 6628c249c7..dde084d0c3 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. 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. + - `multi_runner_config_v2`: Provider-oriented runner configurations keyed by configuration name. To opt into v2, leave `multi_runner_config` empty and populate this map. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module. Populating both maps in the same module instance is unsupported. Each `multi_runner_config_v2` entry supports the following nested fields: diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 66492cefce..ebc1a085fa 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`. 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. +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. Provider outputs identify ownership through their populated provider key rather than a duplicate type field. ## Tagging @@ -122,7 +122,7 @@ yarn run dist | 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. | +| [provider](#output\_provider) | Provider-specific resources grouped by compute provider. | | [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 f554ad19f6..405befa806 100644 --- a/modules/runner-stack/outputs.tf +++ b/modules/runner-stack/outputs.tf @@ -21,9 +21,8 @@ output "pool" { } output "provider" { - description = "Selected compute provider type and its provider-specific resources." + description = "Provider-specific resources grouped by compute provider." value = { - type = local.provider.type - ec2 = local.provider.resources + ec2 = local.provider.resources } } diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf index bc4a3ce25d..ebc1518ea6 100644 --- a/modules/runner-stack/pool.tf +++ b/modules/runner-stack/pool.tf @@ -55,7 +55,7 @@ module "pool" { aws_partition = var.aws_partition tracing_config = var.observability.tracing runner_provider = { - type = local.provider.type + type = local.provider_type 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 diff --git a/modules/runner-stack/scale-runners.tf b/modules/runner-stack/scale-runners.tf index bf28050faf..a9821ce858 100644 --- a/modules/runner-stack/scale-runners.tf +++ b/modules/runner-stack/scale-runners.tf @@ -69,7 +69,7 @@ module "scale_runners" { } runner_provider = { - type = local.provider.type + type = local.provider_type scale_up = { environment_variables = local.provider.environment_variables.scale_up iam_policy_json = local.provider.policies.scale_up.iam_policy_json diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index c9c775ea27..110c520838 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -98,8 +98,8 @@ run "plan_with_pool_enabled" { } assert { - condition = output.provider.type == "ec2" - error_message = "The runner stack must expose the selected compute provider type." + condition = toset(keys(output.provider)) == toset(["ec2"]) + error_message = "The runner stack must identify provider resources through the populated provider key without a duplicate type field." } assert { From 68eb8014a0ecd05166aa26e5a6b1df8ca0347dae Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 7 Aug 2026 22:54:46 +0200 Subject: [PATCH 06/49] refactor(runner-stack): key provider output dynamically --- modules/multi-runner/tests/provider-routing.tftest.hcl | 2 +- modules/multi-runner/webhook.tf | 8 ++++---- modules/runner-stack/README.md | 2 +- modules/runner-stack/outputs.tf | 4 ++-- modules/runner-stack/scale-runners/scale-down.tf | 2 +- modules/runner-stack/scale-runners/scale-up.tf | 2 +- .../scale-runners/tests/scale-runners.tftest.hcl | 3 ++- modules/runner-stack/tests/pool.tftest.hcl | 7 +++++-- modules/webhook/variables.tf | 2 +- 9 files changed, 18 insertions(+), 14 deletions(-) diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 2e12fd097d..720f18dbd0 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -203,7 +203,7 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( local.compute_provider_types["linux"] == "ec2" - && local.runner_matcher_config["linux"].runnerProvider == "ec2" + && local.runner_matcher_config["linux"].computeProvider == "ec2" ) error_message = "Compute-provider selection must supply the webhook routing contract." } diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index 2e829d6a0b..96850ba485 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -1,10 +1,10 @@ locals { runner_matcher_config = { for k, v in local.multi_runner_config : k => { - id = aws_sqs_queue.queued_builds[k].id - arn = aws_sqs_queue.queued_builds[k].arn - runnerProvider = local.compute_provider_types[k] - matcherConfig = v.matcherConfig + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + computeProvider = local.compute_provider_types[k] + matcherConfig = v.matcherConfig } } } diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index ebc1a085fa..734f773bb6 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -122,7 +122,7 @@ yarn run dist | Name | Description | |------|-------------| | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | -| [provider](#output\_provider) | Provider-specific resources grouped by compute provider. | +| [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | | [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 405befa806..3c108857d2 100644 --- a/modules/runner-stack/outputs.tf +++ b/modules/runner-stack/outputs.tf @@ -21,8 +21,8 @@ output "pool" { } output "provider" { - description = "Provider-specific resources grouped by compute provider." + description = "Provider-specific resources grouped under the selected provider key." value = { - ec2 = local.provider.resources + (local.provider_type) = local.provider.resources } } diff --git a/modules/runner-stack/scale-runners/scale-down.tf b/modules/runner-stack/scale-runners/scale-down.tf index f9fb7fcd3f..5e8253728b 100644 --- a/modules/runner-stack/scale-runners/scale-down.tf +++ b/modules/runner-stack/scale-runners/scale-down.tf @@ -31,7 +31,7 @@ resource "aws_lambda_function" "scale_down" { 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 + COMPUTE_PROVIDER_TYPE = var.runner_provider.type }) } diff --git a/modules/runner-stack/scale-runners/scale-up.tf b/modules/runner-stack/scale-runners/scale-up.tf index 51267b82b2..e87ab76c44 100644 --- a/modules/runner-stack/scale-runners/scale-up.tf +++ b/modules/runner-stack/scale-runners/scale-up.tf @@ -38,7 +38,7 @@ resource "aws_lambda_function" "scale_up" { 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 + COMPUTE_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 diff --git a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl index ef7040226d..d6903a7f74 100644 --- a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl @@ -202,7 +202,8 @@ run "assembles_provider_neutral_scaling_control_plane" { assert { condition = ( - aws_lambda_function.scale_up.environment[0].variables["RUNNER_PROVIDER_TYPE"] == "microvm" + aws_lambda_function.scale_up.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "microvm" + && aws_lambda_function.scale_down.environment[0].variables["COMPUTE_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") diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index 110c520838..f7e575369b 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -152,8 +152,11 @@ run "plan_with_pool_enabled" { } assert { - 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." + condition = ( + module.scale_runners.scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + && module.scale_runners.scale_down.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + ) + error_message = "Scaling Lambdas must receive the provider type from the selected provider." } assert { diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index 1a66072ef8..2e5fafd205 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -23,7 +23,7 @@ variable "tags" { } variable "runner_matcher_config" { - description = "SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is currently the only Terraform-managed provider. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`." + description = "SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`." type = map(object({ arn = string id = string From 4dfc15117e0a5faeeedb3b4c440aecf97b3eef87 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 13 Aug 2026 14:08:35 +0200 Subject: [PATCH 07/49] refactor(compute-providers): isolate EC2 runner contracts --- .github/workflows/terraform.yml | 2 + modules/compute-providers/ec2/outputs.tf | 48 +++----- .../ec2/provider-contract.tf | 34 ++++++ .../compute-providers/ec2/runner-instances.tf | 7 -- .../ec2/tests/provider.tftest.hcl | 40 ++++++- .../ec2/trust-policy/assume-role.tf | 18 +++ .../ec2/trust-policy/outputs.tf | 4 + .../tests/trust-policy.tftest.hcl | 67 +++++++++++ .../ec2/trust-policy/variables.tf | 10 ++ .../ec2/trust-policy/versions.tf | 10 ++ modules/compute-providers/ec2/validations.tf | 53 +++++++++ modules/compute-providers/ec2/variables.tf | 70 +++--------- modules/compute-providers/ec2/versions.tf | 2 +- modules/multi-runner/compute-provider.tf | 5 +- modules/multi-runner/main.tf | 2 +- modules/multi-runner/runners.experimental.tf | 106 ++++++++++-------- .../tests/provider-routing.tftest.hcl | 56 ++++----- .../multi-runner/variables.experimental.tf | 40 +++---- modules/multi-runner/variables.tf | 9 +- modules/runner-stack/compute-provider.tf | 10 +- modules/runner-stack/ec2.tf | 10 +- modules/runner-stack/outputs.tf | 2 +- modules/runner-stack/pool.tf | 8 +- modules/runner-stack/runner-role.tf | 42 ++----- modules/runner-stack/scale-runners.tf | 14 +-- modules/runner-stack/tests/pool.tftest.hcl | 86 +++++++------- .../variables.compute-provider.tf | 45 +------- modules/runner-stack/variables.tf | 18 ++- modules/webhook/variables.tf | 16 +-- 29 files changed, 495 insertions(+), 339 deletions(-) create mode 100644 modules/compute-providers/ec2/provider-contract.tf create mode 100644 modules/compute-providers/ec2/trust-policy/assume-role.tf create mode 100644 modules/compute-providers/ec2/trust-policy/outputs.tf create mode 100644 modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl create mode 100644 modules/compute-providers/ec2/trust-policy/variables.tf create mode 100644 modules/compute-providers/ec2/trust-policy/versions.tf create mode 100644 modules/compute-providers/ec2/validations.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index e5c2a54425..4a6def3312 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/trust-policy", "runner-binaries-syncer", "runner-stack", "runner-stack/job-retry", @@ -226,6 +227,7 @@ jobs: - modules/runner-stack/scale-runners - modules/runner-stack/ssm-housekeeper - modules/compute-providers/ec2 + - modules/compute-providers/ec2/trust-policy defaults: run: working-directory: ${{ matrix.module }} diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf index aaab987746..4e536fcbbe 100644 --- a/modules/compute-providers/ec2/outputs.tf +++ b/modules/compute-providers/ec2/outputs.tf @@ -1,35 +1,23 @@ +output "environment_variables" { + description = "Provider-specific Lambda environment variable fragments consumed by runner-stack." + value = local.provider_environment_variables +} + +output "policies" { + description = "Provider-specific IAM policy fragments consumed by runner-stack." + value = local.provider_policies +} + +output "resources" { + description = "Provider-specific EC2 resources exposed by runner-stack." + value = local.provider_resources +} + output "provider" { description = "Nested EC2 compute-provider contract consumed by runner-stack." value = { - environment_variables = { - scale_up = local.scale_up_environment_variables - scale_down = local.scale_down_environment_variables - pool = local.pool_environment_variables - } - 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 - } - } - resources = { - launch_template = aws_launch_template.runner - runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, []) - logfiles = local.logfiles - } + environment_variables = local.provider_environment_variables + policies = local.provider_policies + resources = local.provider_resources } } diff --git a/modules/compute-providers/ec2/provider-contract.tf b/modules/compute-providers/ec2/provider-contract.tf new file mode 100644 index 0000000000..5682496d78 --- /dev/null +++ b/modules/compute-providers/ec2/provider-contract.tf @@ -0,0 +1,34 @@ +locals { + provider_environment_variables = { + scale_up = local.scale_up_environment_variables + scale_down = local.scale_down_environment_variables + pool = local.pool_environment_variables + } + + provider_policies = { + runner = { + inline_policies = local.runner_inline_policies + managed_policy_arns = var.runner.iam.managed_policy_arns + } + scale_up = { + iam_policy_json = local.scale_up_iam_policy_json + additional_iam_policy_json = local.service_linked_role_policy_json + managed_policy_enabled = local.ami_id_ssm_external + managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null + } + scale_down = { + iam_policy_json = local.scale_down_iam_policy_json + } + pool = { + iam_policy_json = local.pool_iam_policy_json + managed_policy_enabled = local.ami_id_ssm_external + managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null + } + } + + provider_resources = { + launch_template = aws_launch_template.runner + runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, []) + logfiles = local.logfiles + } +} diff --git a/modules/compute-providers/ec2/runner-instances.tf b/modules/compute-providers/ec2/runner-instances.tf index a4cd44b4ba..e965d8f66c 100644 --- a/modules/compute-providers/ec2/runner-instances.tf +++ b/modules/compute-providers/ec2/runner-instances.tf @@ -149,13 +149,6 @@ resource "aws_ssm_parameter" "runner_ami_id" { resource "aws_launch_template" "runner" { name = "${var.prefix}-action-runner" - lifecycle { - precondition { - 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.config.block_device_mappings != null ? var.config.block_device_mappings : [] content { diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl index fb504fb9c9..9e733d2e72 100644 --- a/modules/compute-providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -72,6 +72,9 @@ variables { arn = "arn:aws:iam::123456789012:role/provider-test-runner" name = "provider-test-runner" } + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } } } @@ -176,6 +179,11 @@ run "separates_control_plane_contract_from_ec2_resources" { error_message = "The EC2 provider must return the enabled runner permission documents." } + assert { + condition = output.provider.policies.runner.managed_policy_arns["readonly"] == "arn:aws:iam::aws:policy/ReadOnlyAccess" + error_message = "The EC2 provider must return common managed runner policy inputs with its provider policies." + } + assert { condition = toset(keys(output.provider.resources)) == toset(["launch_template", "runners_log_groups", "logfiles"]) error_message = "EC2-specific artifacts must remain nested under provider resources." @@ -391,6 +399,36 @@ run "separates_provider_runner_and_ssm_tags" { } } +run "rejects_external_instance_profile_with_managed_role" { + command = plan + + variables { + config = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-profile" + } + binaries_syncer = { + enabled = false + } + } + + runner = { + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/provider-test-runner" + name = "provider-test-runner" + managed = true + } + } + } + } + + expect_failures = [terraform_data.validate_config] +} + run "requires_distribution_object_when_sync_is_enabled" { command = plan @@ -406,5 +444,5 @@ run "requires_distribution_object_when_sync_is_enabled" { } } - expect_failures = [var.config] + expect_failures = [terraform_data.validate_config] } diff --git a/modules/compute-providers/ec2/trust-policy/assume-role.tf b/modules/compute-providers/ec2/trust-policy/assume-role.tf new file mode 100644 index 0000000000..bea81c1b9e --- /dev/null +++ b/modules/compute-providers/ec2/trust-policy/assume-role.tf @@ -0,0 +1,18 @@ +data "aws_iam_policy_document" "default" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "assume_role" { + source_policy_documents = compact([ + data.aws_iam_policy_document.default.json, + var.additional_trust_policy_json, + ]) +} diff --git a/modules/compute-providers/ec2/trust-policy/outputs.tf b/modules/compute-providers/ec2/trust-policy/outputs.tf new file mode 100644 index 0000000000..0c28bf1661 --- /dev/null +++ b/modules/compute-providers/ec2/trust-policy/outputs.tf @@ -0,0 +1,4 @@ +output "assume_role_policy" { + description = "EC2 runner-role trust policy with the optional additional trust policy merged into it." + value = data.aws_iam_policy_document.assume_role.json +} diff --git a/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl b/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl new file mode 100644 index 0000000000..e764e63fd4 --- /dev/null +++ b/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl @@ -0,0 +1,67 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{}" + } + } +} + +run "returns_default_ec2_trust_policy" { + command = plan + + assert { + condition = toset(data.aws_iam_policy_document.default.statement[0].actions) == toset(["sts:AssumeRole"]) + error_message = "The default EC2 runner role trust policy must allow sts:AssumeRole." + } + + assert { + condition = anytrue([ + for principal in data.aws_iam_policy_document.default.statement[0].principals : + principal.type == "Service" && toset(principal.identifiers) == toset(["ec2.amazonaws.com"]) + ]) + error_message = "The default EC2 runner role trust policy must trust the EC2 service principal." + } + + assert { + condition = ( + length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 1 + && output.assume_role_policy == data.aws_iam_policy_document.assume_role.json + ) + error_message = "The submodule must return the final EC2 assume-role policy." + } +} + +run "merges_additional_trust_policy" { + command = plan + + variables { + additional_trust_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "TrustedAccount" + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { AWS = "arn:aws:iam::123456789012:root" } + }] + }) + } + + assert { + condition = ( + length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 2 + && data.aws_iam_policy_document.assume_role.source_policy_documents[1] == var.additional_trust_policy_json + && output.assume_role_policy == data.aws_iam_policy_document.assume_role.json + ) + error_message = "The submodule must merge the additional trust policy into the final assume-role policy." + } +} + +run "rejects_invalid_additional_trust_policy" { + command = plan + + variables { + additional_trust_policy_json = "not-json" + } + + expect_failures = [var.additional_trust_policy_json] +} diff --git a/modules/compute-providers/ec2/trust-policy/variables.tf b/modules/compute-providers/ec2/trust-policy/variables.tf new file mode 100644 index 0000000000..875fa44f4a --- /dev/null +++ b/modules/compute-providers/ec2/trust-policy/variables.tf @@ -0,0 +1,10 @@ +variable "additional_trust_policy_json" { + description = "Optional IAM policy document merged with the default EC2 runner-role trust policy." + type = string + default = null + + validation { + condition = var.additional_trust_policy_json == null ? true : can(jsondecode(var.additional_trust_policy_json)) + error_message = "additional_trust_policy_json must be valid JSON when set." + } +} diff --git a/modules/compute-providers/ec2/trust-policy/versions.tf b/modules/compute-providers/ec2/trust-policy/versions.tf new file mode 100644 index 0000000000..3ef011ea0a --- /dev/null +++ b/modules/compute-providers/ec2/trust-policy/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/compute-providers/ec2/validations.tf b/modules/compute-providers/ec2/validations.tf new file mode 100644 index 0000000000..ff59bafc13 --- /dev/null +++ b/modules/compute-providers/ec2/validations.tf @@ -0,0 +1,53 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + condition = contains(["spot", "on-demand"], var.config.instance_target_capacity_type) + error_message = "compute_provider.ec2.instance_target_capacity_type must be spot or on-demand." + } + + precondition { + condition = contains( + ["lowest-price", "diversified", "capacity-optimized", "capacity-optimized-prioritized", "price-capacity-optimized", "prioritized"], + var.config.instance_allocation_strategy, + ) + error_message = "compute_provider.ec2.instance_allocation_strategy is not supported." + } + + precondition { + condition = var.config.credit_specification == null ? true : contains(["standard", "unlimited"], var.config.credit_specification) + error_message = "compute_provider.ec2.credit_specification must be null, standard, or unlimited." + } + + precondition { + condition = var.config.cpu_options == null ? true : ( + (var.config.cpu_options.amd_sev_snp == null ? true : contains(["enabled", "disabled"], var.config.cpu_options.amd_sev_snp)) && + (var.config.cpu_options.nested_virtualization == null ? true : contains(["enabled", "disabled"], var.config.cpu_options.nested_virtualization)) + ) + error_message = "compute_provider.ec2.cpu_options amd_sev_snp and nested_virtualization must be enabled or disabled when set." + } + + precondition { + condition = !var.config.binaries_syncer.enabled || var.config.binaries_syncer.s3 != null + error_message = "compute_provider.ec2.binaries_syncer.s3 must be set when compute_provider.ec2.binaries_syncer.enabled is true." + } + + precondition { + condition = var.config.instance_profile == null || !var.runner.iam.role.managed + error_message = "runner.iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." + } + } +} + +resource "terraform_data" "validate_runner" { + lifecycle { + precondition { + condition = contains(["linux", "osx", "windows"], var.runner.os) + error_message = "runner.os must be linux, osx, or windows." + } + + precondition { + condition = length(var.runner.name_prefix) <= 45 + error_message = "runner.name_prefix must be at most 45 characters." + } + } +} diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf index 49ce691faa..68d7e2d138 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/ec2/variables.tf @@ -5,18 +5,18 @@ variable "aws_partition" { } variable "aws_region" { - description = "AWS region used to construct provider-owned runner policy ARNs." + description = "AWS region used by compute-provider resources and policy documents." type = string } variable "prefix" { - description = "Prefix used to name EC2 provider resources." + description = "Prefix used to identify resources created for the runner stack." type = string default = "github-actions" } variable "tags" { - description = "Base tags added to taggable EC2 provider resources. Nested SSM, log, and runner tags override this map within their documented scopes." + description = "Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes." type = map(string) default = {} } @@ -260,39 +260,11 @@ variable "config" { }) nullable = false - - validation { - condition = contains(["spot", "on-demand"], var.config.instance_target_capacity_type) - error_message = "config.instance_target_capacity_type must be spot or on-demand." - } - - validation { - 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." - } - - validation { - 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." - } - - 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." - } - - 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 "runner" { description = <<-EOT - Provider-neutral runner settings consumed by EC2. + Provider-neutral runner settings consumed by compute providers. - `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`. - `architecture`: Runner distribution architecture. @@ -302,9 +274,11 @@ variable "runner" { - `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`. + - `iam.role.arn`: Resolved runner-role ARN referenced by provider policies and resources. + - `iam.role.name`: Resolved runner-role name used by provider resources. + - `iam.role.managed`: Whether runner-stack manages the resolved runner role. + - `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-stack. + - `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. EOT type = object({ os = optional(string, "linux") @@ -319,29 +293,21 @@ variable "runner" { }), {}) iam = object({ role = object({ - arn = string - name = string + arn = string + name = string + managed = optional(bool, true) }) - path = optional(string, null) + managed_policy_arns = optional(map(string), {}) + path = optional(string, null) }) }) nullable = false - - validation { - condition = contains(["linux", "osx", "windows"], var.runner.os) - error_message = "runner.os must be linux, osx, or windows." - } - - validation { - condition = length(var.runner.name_prefix) <= 45 - error_message = "runner.name_prefix must be at most 45 characters." - } } variable "github" { description = <<-EOT - GitHub Enterprise Server settings used to render runner bootstrap data. + GitHub Enterprise Server settings available to compute-provider 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. @@ -358,7 +324,7 @@ variable "github" { variable "ssm" { description = <<-EOT - Parameter Store paths and tag scopes used by EC2 runner bootstrap resources. + Parameter Store paths and tag scopes available to compute-provider 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. @@ -383,9 +349,9 @@ variable "ssm" { variable "observability" { description = <<-EOT - CloudWatch Logs settings used by EC2 runner log groups. + CloudWatch Logs settings available to compute-provider runner log groups. - - `logs.retention_in_days`: Retention period for EC2 runner log groups. + - `logs.retention_in_days`: Retention period for provider-owned 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 diff --git a/modules/compute-providers/ec2/versions.tf b/modules/compute-providers/ec2/versions.tf index da9769f550..3ef011ea0a 100644 --- a/modules/compute-providers/ec2/versions.tf +++ b/modules/compute-providers/ec2/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/multi-runner/compute-provider.tf b/modules/multi-runner/compute-provider.tf index 888c9f066a..83793f34f2 100644 --- a/modules/multi-runner/compute-provider.tf +++ b/modules/multi-runner/compute-provider.tf @@ -7,9 +7,10 @@ locals { } runner_config_by_provider = { - ec2 = { + for provider_type in toset(values(local.compute_provider_types)) : + provider_type => { for runner_key, runner_config in local.multi_runner_config : runner_key => runner_config - if local.compute_provider_types[runner_key] == "ec2" + if local.compute_provider_types[runner_key] == provider_type } } } diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 43dcf51065..47251d359c 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -34,7 +34,7 @@ locals { ) } tmp_distinct_list_unique_os_and_arch = distinct([ - for _, config in local.runner_config_by_provider.ec2 : { + for _, config in try(local.runner_config_by_provider.ec2, {}) : { "os_type" : config.runner.os, "architecture" : config.runner.architecture } diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index 51a881f80b..22fd453f1b 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -9,6 +9,58 @@ locals { }) }) } + + runner_config_v2_compute_provider = { + for k, v in local.runner_config_v2 : k => merge( + local.compute_provider_types[k] == "ec2" ? { + (local.compute_provider_types[k]) = { + ami = v.compute_provider.ec2.ami + vpc_id = coalesce(v.compute_provider.ec2.vpc_id, var.vpc_id) + subnet_ids = coalesce(v.compute_provider.ec2.subnet_ids, var.subnet_ids) + instance_types = v.compute_provider.ec2.instance_types + instance_target_capacity_type = v.compute_provider.ec2.instance_target_capacity_type + instance_allocation_strategy = v.compute_provider.ec2.instance_allocation_strategy + instance_type_priorities = v.compute_provider.ec2.instance_type_priorities + instance_max_spot_price = v.compute_provider.ec2.instance_max_spot_price + block_device_mappings = v.compute_provider.ec2.block_device_mappings + ebs_optimized = v.compute_provider.ec2.ebs_optimized + instance_profile = v.compute_provider.ec2.instance_profile + instance_profile_path = var.instance_profile_path + enable_on_demand_failover_for_errors = v.compute_provider.ec2.enable_on_demand_failover_for_errors + scale_errors = v.compute_provider.ec2.scale_errors + managed_security_group_enabled = var.enable_managed_runner_security_group + detailed_monitoring_enabled = v.compute_provider.ec2.detailed_monitoring_enabled + ssm_enabled = v.compute_provider.ec2.ssm_enabled + egress_rules = var.runner_egress_rules + additional_security_group_ids = try(coalescelist(v.compute_provider.ec2.additional_security_group_ids, var.runner_additional_security_group_ids), []) + metadata_options = v.compute_provider.ec2.metadata_options + credit_specification = v.compute_provider.ec2.credit_specification + cpu_options = v.compute_provider.ec2.cpu_options + placement = v.compute_provider.ec2.placement + license_specifications = v.compute_provider.ec2.license_specifications + use_dedicated_host = v.compute_provider.ec2.use_dedicated_host + binaries_syncer = { + enabled = v.compute_provider.ec2.binaries_syncer.enabled + s3 = v.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map["${v.runner.os}_${v.runner.architecture}"] : null + } + cloudwatch_agent = { + enabled = v.compute_provider.ec2.cloudwatch_agent.enabled + config = try(coalesce(v.compute_provider.ec2.cloudwatch_agent.config, var.cloudwatch_config), null) + } + log_files = v.compute_provider.ec2.log_files + user_data = v.compute_provider.ec2.user_data + key_name = var.key_name + tags = v.compute_provider.ec2.tags + + create_service_linked_role_spot = v.compute_provider.ec2.create_service_linked_role_spot + associate_public_ipv4_address = var.associate_public_ipv4_address + } + } : {}, + local.compute_provider_types[k] != "ec2" ? { + (local.compute_provider_types[k]) = v.compute_provider[local.compute_provider_types[k]] + } : {}, + ) + } } module "runner_stacks" { @@ -37,10 +89,11 @@ module "runner_stacks" { 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 + role = each.value.runner.iam.role + managed_policy_arns = each.value.runner.iam.managed_policy_arns + additional_trust_policy_json = each.value.runner.iam.additional_trust_policy_json + 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 } } @@ -149,48 +202,5 @@ module "runner_stacks" { 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 - } - } + compute_provider = local.runner_config_v2_compute_provider[each.key] } diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 720f18dbd0..7a23617a8f 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -188,7 +188,20 @@ run "experimental_v2_routes_through_provider_stack" { } } matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + labelMatchers = [["self-hosted", "linux", "x64"]] + enableDynamicLabels = true + awsDynamicLabelsPolicy = { + blocked_keys = ["image-id"] + restricted_keys = { + "instance-type" = { + allowed = ["m5.*", "c5.*"] + denied = ["*.metal"] + } + "ebs-volume-size" = { + max = 200 + } + } + } } } } @@ -218,6 +231,16 @@ run "experimental_v2_routes_through_provider_stack" { error_message = "Experimental runner labels must include labels declared by its matcher configuration." } + assert { + condition = ( + local.runner_matcher_config["linux"].matcherConfig.awsDynamicLabelsPolicy.blocked_keys == tolist(["image-id"]) + && local.runner_matcher_config["linux"].matcherConfig.awsDynamicLabelsPolicy.restricted_keys["instance-type"].allowed == tolist(["m5.*", "c5.*"]) + && local.runner_matcher_config["linux"].matcherConfig.awsDynamicLabelsPolicy.restricted_keys["instance-type"].denied == tolist(["*.metal"]) + && local.runner_matcher_config["linux"].matcherConfig.awsDynamicLabelsPolicy.restricted_keys["ebs-volume-size"].max == "200" + ) + error_message = "Experimental matcher config must preserve the typed AWS dynamic-label policy contract." + } + assert { condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["linux"] error_message = "Experimental multi_runner_config_v2 entries must dispatch through module.runner_stacks." @@ -508,34 +531,3 @@ run "experimental_v2_rejects_empty_compute_provider" { expect_failures = [var.experimental] } - -run "experimental_v2_rejects_profile_without_role" { - command = plan - - variables { - experimental = { - multi_runner_config_v2 = { - invalid_profile = { - runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - } - compute_provider = { - ec2 = { - instance_types = ["m5.large"] - instance_profile = { - name = "external-profile" - } - } - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } - } - } - } - } - - expect_failures = [var.experimental] -} diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index dde084d0c3..da5860f01f 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -25,6 +25,7 @@ variable "experimental" { - `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. - `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. @@ -68,7 +69,7 @@ variable "experimental" { - `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`: EC2-specific configuration. - `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. @@ -172,9 +173,10 @@ variable "experimental" { role = optional(object({ arn = string }), null) - managed_policy_arns = optional(map(string), {}) - path = optional(string, null) - permissions_boundary = optional(string, null) + managed_policy_arns = optional(map(string), {}) + additional_trust_policy_json = optional(string, null) + path = optional(string, null) + permissions_boundary = optional(string, null) }), {}) }) @@ -368,13 +370,6 @@ variable "experimental" { })), 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({ @@ -383,7 +378,14 @@ variable "experimental" { bidirectionalLabelMatch = optional(bool, false) priority = optional(number, 999) enableDynamicLabels = optional(bool, false) - awsDynamicLabelsPolicy = optional(any, null) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) }) })), {}) }) @@ -391,7 +393,7 @@ variable "experimental" { validation { condition = alltrue([ - for _, runner_config in var.experimental.multi_runner_config_v2 : + for runner_config in values(var.experimental.multi_runner_config_v2) : length([ for provider_type, provider_config in runner_config.compute_provider : provider_type if provider_config != null @@ -402,17 +404,7 @@ variable "experimental" { validation { condition = alltrue([ - for _, runner_config in var.experimental.multi_runner_config_v2 : - runner_config.compute_provider.ec2 == null ? true : ( - runner_config.compute_provider.ec2.instance_profile == null || runner_config.runner.iam.role != null - ) - ]) - error_message = "runner.iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." - } - - validation { - condition = alltrue([ - for _, runner_config in var.experimental.multi_runner_config_v2 : + for runner_config in values(var.experimental.multi_runner_config_v2) : runner_config.runner.iam.role == null || length(runner_config.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/multi-runner/variables.tf b/modules/multi-runner/variables.tf index c4d5775559..97f9785d45 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -240,7 +240,14 @@ variable "multi_runner_config" { bidirectionalLabelMatch = optional(bool, false) priority = optional(number, 999) enableDynamicLabels = optional(bool, false) - awsDynamicLabelsPolicy = optional(any, null) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) }) redrive_build_queue = optional(object({ enabled = bool diff --git a/modules/runner-stack/compute-provider.tf b/modules/runner-stack/compute-provider.tf index 9b63c0b64a..78e00bf03c 100644 --- a/modules/runner-stack/compute-provider.tf +++ b/modules/runner-stack/compute-provider.tf @@ -4,9 +4,15 @@ locals { if provider_config != null ]) - provider_modules = { + provider_assume_role_policies = { + ec2 = try(module.ec2_trust_policy[0].assume_role_policy, null) + } + + provider_assume_role_policy = local.provider_assume_role_policies[local.provider_type] + + provider_contracts = { ec2 = one(module.ec2[*].provider) } - provider = local.provider_modules[local.provider_type] + provider_contract = local.provider_contracts[local.provider_type] } diff --git a/modules/runner-stack/ec2.tf b/modules/runner-stack/ec2.tf index ba734bf3f8..071174f859 100644 --- a/modules/runner-stack/ec2.tf +++ b/modules/runner-stack/ec2.tf @@ -1,3 +1,10 @@ +module "ec2_trust_policy" { + count = local.provider_type == "ec2" ? 1 : 0 + source = "../compute-providers/ec2/trust-policy" + + additional_trust_policy_json = var.runner.iam.additional_trust_policy_json +} + module "ec2" { count = local.provider_type == "ec2" ? 1 : 0 source = "../compute-providers/ec2" @@ -10,7 +17,8 @@ module "ec2" { config = var.compute_provider.ec2 runner = merge(var.runner, { iam = merge(var.runner.iam, { - role = local.runner_role + role = local.runner_role + managed_policy_arns = local.common_runner_managed_policy_arns }) }) github = var.github diff --git a/modules/runner-stack/outputs.tf b/modules/runner-stack/outputs.tf index 3c108857d2..b7b03822cb 100644 --- a/modules/runner-stack/outputs.tf +++ b/modules/runner-stack/outputs.tf @@ -23,6 +23,6 @@ output "pool" { output "provider" { description = "Provider-specific resources grouped under the selected provider key." value = { - (local.provider_type) = local.provider.resources + (local.provider_type) = local.provider_contract.resources } } diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf index ebc1518ea6..0e075b1378 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.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 + environment_variables = local.provider_contract.environment_variables.pool + iam_policy_json = local.provider_contract.policies.pool.iam_policy_json + managed_policy_enabled = local.provider_contract.policies.pool.managed_policy_enabled + managed_policy_arn = local.provider_contract.policies.pool.managed_policy_arn } } diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-stack/runner-role.tf index 588cdcfa76..0422487d7a 100644 --- a/modules/runner-stack/runner-role.tf +++ b/modules/runner-stack/runner-role.tf @@ -1,17 +1,16 @@ locals { - # 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. + # Role ownership belongs to the common stack. The selected trust-policy + # submodule supplies the assume-role document, while the full compute provider + # supplies permissions after the role has been resolved. 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 - 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) + managed = local.create_runner_role } - provider_runner_policies = local.provider.policies.runner - - runner_managed_policy_arns = merge( + common_runner_managed_policy_arns = merge( { for policy_name, policy_arn in var.runner.iam.managed_policy_arns : "user-${policy_name}" => policy_arn @@ -19,39 +18,18 @@ locals { var.observability.tracing.mode != null ? { xray = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" } : {}, - { - 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"] : [] - } - } + provider_runner_policies = local.provider_contract.policies.runner } 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 = data.aws_iam_policy_document.runner_assume_role.json + assume_role_policy = local.provider_assume_role_policy path = local.runner_role_path permissions_boundary = var.runner.iam.permissions_boundary tags = local.runner_tags - - lifecycle { - precondition { - 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" { @@ -63,7 +41,7 @@ resource "aws_iam_role_policy" "runner_provider" { } resource "aws_iam_role_policy_attachment" "runner" { - for_each = local.create_runner_role ? local.runner_managed_policy_arns : {} + for_each = local.create_runner_role ? local.provider_runner_policies.managed_policy_arns : {} role = aws_iam_role.runner[0].name policy_arn = each.value diff --git a/modules/runner-stack/scale-runners.tf b/modules/runner-stack/scale-runners.tf index a9821ce858..ac5ca69845 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.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 + environment_variables = local.provider_contract.environment_variables.scale_up + iam_policy_json = local.provider_contract.policies.scale_up.iam_policy_json + additional_iam_policy_json = local.provider_contract.policies.scale_up.additional_iam_policy_json + managed_policy = local.provider_contract.policies.scale_up.managed_policy_enabled ? { + arn = local.provider_contract.policies.scale_up.managed_policy_arn } : null } scale_down = { - environment_variables = local.provider.environment_variables.scale_down - iam_policy_json = local.provider.policies.scale_down.iam_policy_json + environment_variables = local.provider_contract.environment_variables.scale_down + iam_policy_json = local.provider_contract.policies.scale_down.iam_policy_json } } } diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index f7e575369b..6e14f18fcb 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -47,6 +47,20 @@ variables { runner = { labels = ["self-hosted", "linux", "x64"] + iam = { + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + additional_trust_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AdditionalTrustedAccount" + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { AWS = "arn:aws:iam::210987654321:root" } + }] + }) + } } queue = { @@ -99,7 +113,7 @@ run "plan_with_pool_enabled" { assert { condition = toset(keys(output.provider)) == toset(["ec2"]) - error_message = "The runner stack must identify provider resources through the populated provider key without a duplicate type field." + error_message = "The runner stack must expose resources only under the selected provider key." } assert { @@ -113,11 +127,11 @@ run "plan_with_pool_enabled" { } 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." + condition = ( + length(module.ec2_trust_policy) == 1 + && aws_iam_role.runner[0].assume_role_policy == module.ec2_trust_policy[0].assume_role_policy + ) + error_message = "The common runner role must use the selected EC2 trust-policy submodule output." } assert { @@ -151,6 +165,11 @@ run "plan_with_pool_enabled" { error_message = "The common stack must attach every enabled EC2 runner policy by its stable provider key." } + assert { + condition = aws_iam_role_policy_attachment.runner["user-readonly"].policy_arn == "arn:aws:iam::aws:policy/ReadOnlyAccess" + error_message = "The selected EC2 provider contract must return common managed runner policies for one attachment path." + } + assert { condition = ( module.scale_runners.scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" @@ -248,28 +267,6 @@ run "external_runner_role_and_profile_remain_external" { } } -run "external_profile_requires_external_role" { - command = plan - - variables { - compute_provider = { - ec2 = { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - instance_types = ["m5.large"] - instance_profile = { - name = "external-runner-profile" - } - binaries_syncer = { - enabled = false - } - } - } - } - - expect_failures = [aws_iam_role.runner] -} - run "empty_runner_iam_uses_common_role" { command = plan @@ -306,24 +303,37 @@ run "external_role_rejects_managed_policy_attachments" { expect_failures = [var.runner] } -run "requires_distribution_object_when_sync_is_enabled" { +run "external_role_rejects_trust_policy_extension" { command = plan variables { - compute_provider = { - ec2 = { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - instance_types = ["m5.large"] - binaries_syncer = { - enabled = true - s3 = null + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" } + additional_trust_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" } } } - expect_failures = [var.compute_provider] + expect_failures = [var.runner] +} + +run "rejects_invalid_trust_policy_extension" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + additional_trust_policy_json = "{" + } + } + } + + expect_failures = [var.runner] } run "rejects_empty_compute_provider" { diff --git a/modules/runner-stack/variables.compute-provider.tf b/modules/runner-stack/variables.compute-provider.tf index 3d0c8da129..f8cb67d9a2 100644 --- a/modules/runner-stack/variables.compute-provider.tf +++ b/modules/runner-stack/variables.compute-provider.tf @@ -5,7 +5,7 @@ variable "compute_provider" { 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`: EC2 compute-provider configuration. - `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. @@ -252,47 +252,4 @@ variable "compute_provider" { ]) == 1 error_message = "Exactly one compute-provider block must be set. Supported compute-provider blocks: 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.binaries_syncer.enabled || var.compute_provider.ec2.binaries_syncer.s3 != null - ) - 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/variables.tf b/modules/runner-stack/variables.tf index cfb2257a5b..6adb2e6fcd 100644 --- a/modules/runner-stack/variables.tf +++ b/modules/runner-stack/variables.tf @@ -43,6 +43,7 @@ variable "runner" { - `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. - `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 @@ -69,9 +70,10 @@ variable "runner" { role = optional(object({ arn = string }), null) - managed_policy_arns = optional(map(string), {}) - path = optional(string, null) - permissions_boundary = optional(string, null) + managed_policy_arns = optional(map(string), {}) + additional_trust_policy_json = optional(string, null) + path = optional(string, null) + permissions_boundary = optional(string, null) }), {}) }) @@ -94,6 +96,16 @@ variable "runner" { 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." } + + validation { + condition = var.runner.iam.additional_trust_policy_json == null ? true : can(jsondecode(var.runner.iam.additional_trust_policy_json)) + error_message = "runner.iam.additional_trust_policy_json must be valid JSON when set." + } + + validation { + condition = var.runner.iam.role == null || var.runner.iam.additional_trust_policy_json == null + error_message = "runner.iam.additional_trust_policy_json cannot be set with an external runner.iam.role because external role trust is not managed by this module." + } } variable "github" { diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index 2e5fafd205..a2fae87e4a 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -34,20 +34,20 @@ variable "runner_matcher_config" { bidirectionalLabelMatch = optional(bool, false) priority = optional(number, 999) enableDynamicLabels = optional(bool, false) - awsDynamicLabelsPolicy = optional(any, null) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) }) })) validation { condition = try(var.runner_matcher_config.matcherConfig.priority, 999) >= 0 && try(var.runner_matcher_config.matcherConfig.priority, 999) < 1000 error_message = "The priority of the matcher must be between 0 and 999." } - validation { - condition = alltrue([ - for config in values(var.runner_matcher_config) : - lower(trimspace(config.computeProvider)) == "ec2" - ]) - error_message = "computeProvider must be ec2." - } } variable "lambda_zip" { From 4735d85f3c74b31a25a4c78683bec8893bc059b8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 13 Aug 2026 14:08:53 +0200 Subject: [PATCH 08/49] docs(compute-providers): document EC2 provider boundary --- .../internal/compute-provider-refactor.md | 32 +++++++++------ modules/compute-providers/ec2/README.md | 22 ++++++---- .../ec2/trust-policy/README.md | 41 +++++++++++++++++++ modules/multi-runner/README.md | 8 ++-- modules/runner-stack/README.md | 8 ++-- modules/webhook/README.md | 2 +- 6 files changed, 83 insertions(+), 30 deletions(-) create mode 100644 modules/compute-providers/ec2/trust-policy/README.md diff --git a/docs/modules/internal/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md index 9a8ca49884..7f20e56e0c 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -8,7 +8,7 @@ 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. +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 @@ -22,24 +22,27 @@ The implementation is split into orchestration, provider-neutral control-plane c | `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//trust-policy` | Provider-specific default runner-role trust, merged with the optional caller-provided trust document before the common role is created. | +| `compute-providers/` | Provider-specific resources, permission requirements, and the IAM and environment-variable fragments consumed by the common control plane after the runner role is resolved. | -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 EC2 provider 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 in this phase. 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` 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. +`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. Native input validation enforces this common selection rule, while each compute-provider module owns its provider-specific semantic validation. The stack passes `compute_provider.` to the selected provider 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 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 common stack creates or selects the runner IAM role, but the selected provider owns the role's default trust-policy document. Each provider implements a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. The common stack uses the isolated trust-policy output when it creates the runner role and attaches the full provider's 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: +The trust relationship is deliberately rendered by an isolated provider submodule: -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. +1. `runner-stack` selects the provider from the populated typed block. +2. `compute-providers//trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input. +3. `runner-stack` creates or selects the common runner role from the returned `assume_role_policy`. +4. The full compute provider receives the resolved role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements. +5. The provider returns its nested policy, environment-variable, and resource contract. +6. 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. +The trust-policy output depends only on its input documents, not on the full provider resources that consume the runner role. This preserves provider ownership of the trust relationship while keeping the dependency graph one-way. ## Phase 1 dispatch and compatibility @@ -59,7 +62,10 @@ flowchart TD Stack --> Pool["runner-stack/pool"] Stack --> Retry["runner-stack/job-retry"] Stack --> Housekeeper["runner-stack/ssm-housekeeper"] - Stack --> Provider["compute-providers/ec2"] + Stack --> Trust["compute-providers/provider/trust-policy"] + Trust --> Role["common runner role"] + Role --> Provider + Stack --> Provider["compute-providers/"] Provider --> Scaling Provider --> Pool ``` @@ -117,7 +123,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. -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 populated provider key identifies the compute provider without a duplicate type field. The `pool` value is null when no pool configuration is supplied. +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.`. The provider key is derived dynamically from the selected input block and therefore also identifies the compute provider. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while an EC2 selection places launch-template and runner-log artifacts under `runners_map_v2["configuration"].provider.ec2`. The `pool` value is null when no pool configuration is supplied. ## Plan-time provider selection and ownership wrappers diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index 9cfe95aeb4..7ac103d23c 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -11,7 +11,7 @@ EC2 is the only active compute provider. The parent stack selects it when `ec2` | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers @@ -19,6 +19,7 @@ EC2 is the only active compute provider. The parent stack selects it when `ec2` | Name | Version | |------|---------| | [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -37,6 +38,8 @@ No modules. | [aws_ssm_parameter.runner_ami_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.runner_config_run_as](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.runner_enable_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_runner](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_policy_document.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -57,18 +60,21 @@ No modules. | 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 | +| [aws\_region](#input\_aws\_region) | AWS region used by compute-provider resources and policy documents. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.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 | -| [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 | +| [github](#input\_github) | GitHub Enterprise Server settings available to compute-provider 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 available to compute-provider runner log groups.

- `logs.retention_in_days`: Retention period for provider-owned 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 identify resources created for the runner stack. | `string` | `"github-actions"` | no | +| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-stack manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-stack.
- `iam.path`: IAM path available to provider-managed IAM resources. 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
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | +| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `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 available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | |------|-------------| +| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-stack. | +| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-stack. | | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-stack. | +| [resources](#output\_resources) | Provider-specific EC2 resources exposed by runner-stack. | diff --git a/modules/compute-providers/ec2/trust-policy/README.md b/modules/compute-providers/ec2/trust-policy/README.md new file mode 100644 index 0000000000..3e0c0ce908 --- /dev/null +++ b/modules/compute-providers/ec2/trust-policy/README.md @@ -0,0 +1,41 @@ +# EC2 runner trust policy + +This internal submodule builds the EC2 runner-role trust policy independently from EC2 resources that consume the runner role. It preserves the default EC2 service trust and optionally merges an additional IAM trust policy document supplied by the common runner stack. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [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.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the default EC2 runner-role trust policy. | `string` | `null` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [assume\_role\_policy](#output\_assume\_role\_policy) | EC2 runner-role trust policy with the optional additional trust policy merged into it. | + diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 07ef64ee4b..70d942ca39 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -24,7 +24,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. -To opt into v2, leave `multi_runner_config` empty and populate `experimental.multi_runner_config_v2`. The whole module instance then uses `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 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. +To opt into v2, leave `multi_runner_config` empty and populate `experimental.multi_runner_config_v2`. The whole module instance then uses `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 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. @@ -34,7 +34,7 @@ Phase 1 supports both input contracts, but callers must populate only one runner 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. 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 populated provider key identifies the compute provider without a duplicate type field. +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. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. ### Multi-runner v2 migration roadmap @@ -196,7 +196,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. To opt into v2, leave `multi_runner_config` empty and populate this map. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module. Populating both maps in the same module instance is unsupported.

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 | +| [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. To opt into v2, leave `multi_runner_config` empty and populate this map. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module. Populating both maps in the same module instance is unsupported.

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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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.
- `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), {})
additional_trust_policy_json = optional(string, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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 | @@ -220,7 +220,7 @@ module "multi-runner" { | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no | | [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
})
| `{}` | no | -| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, 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](#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(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = 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 | diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 734f773bb6..1a12714b89 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`. 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. Provider outputs identify ownership through their populated provider key rather than a duplicate type field. +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. Before creating the common runner role, the stack calls the selected provider's isolated `trust-policy` module to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full compute-provider module, which receives the resolved runner role only after it is created. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common stack attaches each returned policy group to its runner, scale-up, scale-down, or pool role. The runner-stack output groups provider-specific resources under the matching dynamic provider key, which also identifies the selected provider. EC2 is the only implemented Terraform compute provider in this phase. ## Tagging @@ -78,6 +78,7 @@ yarn run dist | Name | Source | Version | |------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | +| [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | 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 | @@ -95,7 +96,6 @@ 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.

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 | +| [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.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 | @@ -111,7 +111,7 @@ yarn run dist | [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 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 | +| [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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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), {})
additional_trust_policy_json = optional(string, null)
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 | diff --git a/modules/webhook/README.md b/modules/webhook/README.md index 70121458a7..458ff5a7aa 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -89,7 +89,7 @@ yarn run dist | [repository\_white\_list](#input\_repository\_white\_list) | List of github repository full names (owner/repo\_name) that will be allowed to use the github app. Leave empty for no filtering. | `list(string)` | `[]` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the environment name 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\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| n/a | yes | +| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
| n/a | yes | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
webhook = 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 and environment. | `map(string)` | `{}` | no | | [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | From be6270fad5a259a4093dfbbf05dc3d2cdbe55d2d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 14 Aug 2026 15:08:29 +0200 Subject: [PATCH 09/49] fix(runner-stack): preserve multi-app credentials --- .../tests/provider-routing.tftest.hcl | 25 ++++++++ modules/runner-stack/README.md | 2 +- modules/runner-stack/job-retry/README.md | 2 +- .../runner-stack/job-retry/iam-policies.tf | 9 +-- modules/runner-stack/job-retry/job-retry.tf | 19 +++--- .../job-retry/tests/job-retry.tftest.hcl | 56 ++++++++++++++---- modules/runner-stack/job-retry/variables.tf | 16 ++--- modules/runner-stack/pool/README.md | 2 +- modules/runner-stack/pool/iam-policies.tf | 9 +-- modules/runner-stack/pool/pool.tf | 49 +++++++-------- .../pool/tests/provider.tftest.hcl | 47 ++++++++++++--- modules/runner-stack/pool/variables.tf | 16 +++-- modules/runner-stack/scale-runners/README.md | 2 +- .../scale-runners/scale-down-iam-policies.tf | 9 +-- .../runner-stack/scale-runners/scale-down.tf | 35 +++++------ .../scale-runners/scale-up-iam-policies.tf | 11 ++-- .../runner-stack/scale-runners/scale-up.tf | 59 ++++++++++--------- .../tests/scale-runners.tftest.hcl | 47 ++++++++++++--- .../runner-stack/scale-runners/variables.tf | 16 ++--- .../computed-iam-inputs.tf | 18 +++--- modules/runner-stack/tests/pool.tftest.hcl | 5 +- modules/runner-stack/tests/tags.tftest.hcl | 9 +-- modules/runner-stack/variables.tf | 14 ++--- 23 files changed, 298 insertions(+), 179 deletions(-) diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 7a23617a8f..fda12b8f7e 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -147,6 +147,21 @@ run "experimental_v2_routes_through_provider_stack" { command = plan variables { + additional_github_apps = [{ + id_ssm = { + name = "/github-runner/additional-app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-id" + } + key_base64_ssm = { + name = "/github-runner/additional-key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-key-base64" + } + installation_id_ssm = { + name = "/github-runner/additional-installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-installation-id" + } + }] + experimental = { multi_runner_config_v2 = { linux = { @@ -246,6 +261,16 @@ run "experimental_v2_routes_through_provider_stack" { error_message = "Experimental multi_runner_config_v2 entries must dispatch through module.runner_stacks." } + assert { + condition = ( + length(local.github_app_parameters.id) == 2 + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == join(":", [for p in local.github_app_parameters.id : p.name]) + && module.runner_stacks["linux"].scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) + && module.runner_stacks["linux"].pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) + ) + error_message = "Experimental v2 control-plane Lambdas must preserve the complete multi-app parameter lists from the shared multi-runner configuration." + } + assert { condition = keys(aws_sqs_queue.queued_builds) == ["linux"] error_message = "Common queue ownership must preserve the experimental runner configuration key." diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 1a12714b89..c46e458ed9 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -104,7 +104,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.

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.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 | +| [github](#input\_github) | GitHub API and runner-registration configuration.

- `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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 | diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index ffba2f9636..d8f40a4d51 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) | 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 | +| [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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(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 6f0a3b215e..0979c1532c 100644 --- a/modules/runner-stack/job-retry/iam-policies.tf +++ b/modules/runner-stack/job-retry/iam-policies.tf @@ -57,10 +57,11 @@ data "aws_iam_policy_document" "job_retry" { "ssm:GetParameters", ] - resources = [ - var.config.github.app_parameters.key_base64.arn, - var.config.github.app_parameters.id.arn, - ] + resources = concat( + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], + ) } statement { diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/runner-stack/job-retry/job-retry.tf index a8e873ed3c..1530bafb20 100644 --- a/modules/runner-stack/job-retry/job-retry.tf +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -19,15 +19,16 @@ locals { } job_retry_environment_variables = { - 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 + 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 = join(":", [for p in var.config.github.app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.name : ""]) + RUNNER_NAME_PREFIX = var.config.runner.name_prefix } environment_variables = merge( 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 40cd279e30..88882d1e43 100644 --- a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl +++ b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl @@ -55,14 +55,33 @@ variables { } 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" - } + key_base64 = [ + { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }, + { + name = "/github-runner/key-base64-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2" + }, + ] + id = [ + { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }, + { + name = "/github-runner/app-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2" + }, + ] + installation_id = [ + null, + { + name = "/github-runner/installation-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2" + }, + ] } } queue = { @@ -126,6 +145,18 @@ run "preserves_nested_job_retry_configuration" { error_message = "Required job-retry environment variables must override caller-provided values." } + assert { + condition = ( + output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" + && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64:/github-runner/key-base64-2" + && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/github-runner/installation-id-2" + && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2") + && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2") + && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2") + ) + error_message = "Job retry must pass every GitHub App parameter and grant access to every corresponding SSM ARN." + } + assert { condition = ( toset(keys(output.lambda)) == toset(["function", "log_group", "role"]) @@ -197,14 +228,15 @@ run "does_not_enable_partial_vpc_configuration" { organization_runners = false enterprise_server = {} app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } } queue = { diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/runner-stack/job-retry/variables.tf index 950bd6eb8b..6fd17d05bb 100644 --- a/modules/runner-stack/job-retry/variables.tf +++ b/modules/runner-stack/job-retry/variables.tf @@ -23,8 +23,9 @@ variable "config" { - `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. + - `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `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. @@ -81,14 +82,9 @@ variable "config" { }) user_agent = optional(string, null) app_parameters = object({ - key_base64 = object({ - name = string - arn = string - }) - id = object({ - name = string - arn = string - }) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) }) queue = object({ diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md index 64553579ff..98e5e3fadc 100644 --- a/modules/runner-stack/pool/README.md +++ b/modules/runner-stack/pool/README.md @@ -52,7 +52,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) | 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 | +| [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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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 | diff --git a/modules/runner-stack/pool/iam-policies.tf b/modules/runner-stack/pool/iam-policies.tf index 13690cce09..d0a3cb0b0b 100644 --- a/modules/runner-stack/pool/iam-policies.tf +++ b/modules/runner-stack/pool/iam-policies.tf @@ -34,10 +34,11 @@ data "aws_iam_policy_document" "pool_common" { "ssm:GetParameters", ] - resources = [ - var.config.github_app_parameters.key_base64.arn, - var.config.github_app_parameters.id.arn, - ] + resources = concat( + [for p in var.config.github_app_parameters.id : p.arn], + [for p in var.config.github_app_parameters.key_base64 : p.arn], + [for p in var.config.github_app_parameters.installation_id : p.arn if p != null], + ) } dynamic "statement" { diff --git a/modules/runner-stack/pool/pool.tf b/modules/runner-stack/pool/pool.tf index 5e95c897ca..e44424f179 100644 --- a/modules/runner-stack/pool/pool.tf +++ b/modules/runner-stack/pool/pool.tf @@ -7,30 +7,31 @@ locals { ) 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 + 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 = join(":", [for p in var.config.github_app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github_app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github_app_parameters.installation_id : p != null ? p.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 } } diff --git a/modules/runner-stack/pool/tests/provider.tftest.hcl b/modules/runner-stack/pool/tests/provider.tftest.hcl index b352a03c26..192dd82ad8 100644 --- a/modules/runner-stack/pool/tests/provider.tftest.hcl +++ b/modules/runner-stack/pool/tests/provider.tftest.hcl @@ -34,14 +34,33 @@ variables { 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" - } + key_base64 = [ + { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }, + { + name = "/github-runner/key-base64-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2" + }, + ] + id = [ + { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }, + { + name = "/github-runner/app-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2" + }, + ] + installation_id = [ + null, + { + name = "/github-runner/installation-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2" + }, + ] } runner = { disable_runner_autoupdate = false @@ -103,6 +122,18 @@ run "provider_supplies_only_compute_specific_pool_configuration" { error_message = "The pool module must continue to assemble common runner environment variables." } + assert { + condition = ( + aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" + && aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64:/github-runner/key-base64-2" + && aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/github-runner/installation-id-2" + && contains(data.aws_iam_policy_document.pool_common.statement[2].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2") + && contains(data.aws_iam_policy_document.pool_common.statement[2].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2") + && contains(data.aws_iam_policy_document.pool_common.statement[2].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2") + ) + error_message = "Pool must pass every GitHub App parameter and grant access to every corresponding SSM ARN." + } + 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." diff --git a/modules/runner-stack/pool/variables.tf b/modules/runner-stack/pool/variables.tf index 63cbf90e30..0956209a39 100644 --- a/modules/runner-stack/pool/variables.tf +++ b/modules/runner-stack/pool/variables.tf @@ -23,13 +23,10 @@ variable "config" { - `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. + - `github_app_parameters`: Ordered SSM parameter metadata for GitHub App credentials. + - `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `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. @@ -81,8 +78,9 @@ variable "config" { ssl_verify = string }) github_app_parameters = object({ - key_base64 = map(string) - id = map(string) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) runner = object({ disable_runner_autoupdate = bool diff --git a/modules/runner-stack/scale-runners/README.md b/modules/runner-stack/scale-runners/README.md index 861792c99a..d3113fb1d3 100644 --- a/modules/runner-stack/scale-runners/README.md +++ b/modules/runner-stack/scale-runners/README.md @@ -65,7 +65,7 @@ No modules. | 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 | +| [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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(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 diff --git a/modules/runner-stack/scale-runners/scale-down-iam-policies.tf b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf index c61e8dc68b..c5f1787b52 100644 --- a/modules/runner-stack/scale-runners/scale-down-iam-policies.tf +++ b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf @@ -5,10 +5,11 @@ data "aws_iam_policy_document" "scale_down_common" { "ssm:GetParameter", "ssm:GetParameters", ] - resources = [ - var.config.github.app_parameters.key_base64.arn, - var.config.github.app_parameters.id.arn, - ] + resources = concat( + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], + ) } dynamic "statement" { diff --git a/modules/runner-stack/scale-runners/scale-down.tf b/modules/runner-stack/scale-runners/scale-down.tf index 5e8253728b..a118f31e58 100644 --- a/modules/runner-stack/scale-runners/scale-down.tf +++ b/modules/runner-stack/scale-runners/scale-down.tf @@ -15,23 +15,24 @@ resource "aws_lambda_function" "scale_down" { 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 - COMPUTE_PROVIDER_TYPE = var.runner_provider.type + 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 = join(":", [for p in var.config.github.app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.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 + COMPUTE_PROVIDER_TYPE = var.runner_provider.type }) } diff --git a/modules/runner-stack/scale-runners/scale-up-iam-policies.tf b/modules/runner-stack/scale-runners/scale-up-iam-policies.tf index 2e64d54876..1e32597427 100644 --- a/modules/runner-stack/scale-runners/scale-up-iam-policies.tf +++ b/modules/runner-stack/scale-runners/scale-up-iam-policies.tf @@ -14,11 +14,12 @@ data "aws_iam_policy_document" "scale_up_common" { "ssm:GetParameter", "ssm:GetParameters", ] - resources = [ - var.config.github.app_parameters.key_base64.arn, - var.config.github.app_parameters.id.arn, - "${var.config.ssm.config_path_arn}/*", - ] + resources = concat( + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], + ["${var.config.ssm.config_path_arn}/*"], + ) } statement { diff --git a/modules/runner-stack/scale-runners/scale-up.tf b/modules/runner-stack/scale-runners/scale-up.tf index e87ab76c44..92dfa4267a 100644 --- a/modules/runner-stack/scale-runners/scale-up.tf +++ b/modules/runner-stack/scale-runners/scale-up.tf @@ -16,35 +16,36 @@ resource "aws_lambda_function" "scale_up" { 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 - COMPUTE_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) + 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 = join(":", [for p in var.config.github.app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.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 + COMPUTE_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) }) } diff --git a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl index d6903a7f74..0f383c3b66 100644 --- a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl @@ -55,14 +55,33 @@ variables { } 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" - } + key_base64 = [ + { + name = "/github-runner/key-base64" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64" + }, + { + name = "/github-runner/key-base64-2" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64-2" + }, + ] + id = [ + { + name = "/github-runner/app-id" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id" + }, + { + name = "/github-runner/app-id-2" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id-2" + }, + ] + installation_id = [ + null, + { + name = "/github-runner/installation-id-2" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/installation-id-2" + }, + ] } } queue = { @@ -223,6 +242,18 @@ run "assembles_provider_neutral_scaling_control_plane" { error_message = "Scale runners must assemble shared runner, logging, TLS, lifetime, and Parameter Store environment variables." } + assert { + condition = ( + aws_lambda_function.scale_up.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" + && aws_lambda_function.scale_down.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64:/github-runner/key-base64-2" + && aws_lambda_function.scale_up.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/github-runner/installation-id-2" + && contains(data.aws_iam_policy_document.scale_up_common.statement[1].resources, "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id-2") + && contains(data.aws_iam_policy_document.scale_down_common.statement[0].resources, "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64-2") + && contains(data.aws_iam_policy_document.scale_down_common.statement[0].resources, "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/installation-id-2") + ) + error_message = "Scale-up and scale-down must pass every GitHub App parameter and grant access to every corresponding SSM ARN." + } + 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" diff --git a/modules/runner-stack/scale-runners/variables.tf b/modules/runner-stack/scale-runners/variables.tf index 07146601de..fd1f015595 100644 --- a/modules/runner-stack/scale-runners/variables.tf +++ b/modules/runner-stack/scale-runners/variables.tf @@ -31,8 +31,9 @@ variable "config" { - `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. + - `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `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. @@ -100,14 +101,9 @@ variable "config" { }) user_agent = optional(string, null) app_parameters = object({ - key_base64 = object({ - name = string - arn = string - }) - id = object({ - name = string - arn = string - }) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) }) queue = object({ 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 20bcdbef52..94b6ee2c5d 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 @@ -79,14 +79,15 @@ module "external_iam" { github = { organization_runners = true app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } } @@ -148,14 +149,15 @@ module "generated_policy" { github = { organization_runners = true app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } } diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-stack/tests/pool.tftest.hcl index 6e14f18fcb..4230b222a2 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-stack/tests/pool.tftest.hcl @@ -81,8 +81,9 @@ variables { 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" } + 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" }] + installation_id = [null] } } diff --git a/modules/runner-stack/tests/tags.tftest.hcl b/modules/runner-stack/tests/tags.tftest.hcl index 006c57ea97..0f6f174d4b 100644 --- a/modules/runner-stack/tests/tags.tftest.hcl +++ b/modules/runner-stack/tests/tags.tftest.hcl @@ -82,14 +82,15 @@ variables { github = { organization_runners = true app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } } diff --git a/modules/runner-stack/variables.tf b/modules/runner-stack/variables.tf index 6adb2e6fcd..fc1d46e21b 100644 --- a/modules/runner-stack/variables.tf +++ b/modules/runner-stack/variables.tf @@ -112,12 +112,9 @@ variable "github" { 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. + - `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `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. @@ -125,8 +122,9 @@ variable "github" { EOT type = object({ app_parameters = object({ - key_base64 = map(string) - id = map(string) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) organization_runners = bool enterprise_server = optional(object({ From 519d1d8816c8c1342214538b84913d9621e66d8f Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 14 Aug 2026 15:35:08 +0200 Subject: [PATCH 10/49] fix(multi-runner): keep binary syncer keys plan-known --- modules/multi-runner/main.tf | 3 +- .../tests/computed-runner-inputs.tftest.hcl | 67 +++++++++++++++++++ .../fixtures/computed-runner-inputs/main.tf | 62 +++++++++++++++++ .../computed-runner-inputs/versions.tf | 13 ++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 modules/multi-runner/tests/computed-runner-inputs.tftest.hcl create mode 100644 modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf create mode 100644 modules/multi-runner/tests/fixtures/computed-runner-inputs/versions.tf diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 47251d359c..9226498ad8 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -33,8 +33,9 @@ locals { merge(v, { runner_config = merge(v.runner_config, { runner_extra_labels = local.runner_extra_labels[k] }) }), ) } + # Keep a concrete map type when unrelated configuration values are unknown until apply. tmp_distinct_list_unique_os_and_arch = distinct([ - for _, config in try(local.runner_config_by_provider.ec2, {}) : { + for _, config in lookup(local.runner_config_by_provider, "ec2", {}) : { "os_type" : config.runner.os, "architecture" : config.runner.architecture } diff --git a/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl b/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl new file mode 100644 index 0000000000..0d8fd6435b --- /dev/null +++ b/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl @@ -0,0 +1,67 @@ +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\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/test-role" + } + } + + mock_resource "aws_cloudwatch_event_bus" { + defaults = { + arn = "arn:aws:events:eu-west-1:123456789012:event-bus/test" + } + } + + mock_resource "aws_cloudwatch_event_rule" { + defaults = { + arn = "arn:aws:events:eu-west-1:123456789012:rule/test" + } + } + + mock_resource "aws_lambda_function" { + defaults = { + arn = "arn:aws:lambda:eu-west-1:123456789012:function:test" + } + } + + mock_resource "aws_sqs_queue" { + defaults = { + arn = "arn:aws:sqs:eu-west-1:123456789012:test" + } + } + + mock_resource "aws_apigatewayv2_api" { + defaults = { + execution_arn = "arn:aws:execute-api:eu-west-1:123456789012:test" + } + } +} + +run "computed_lane_values_keep_binary_syncer_instances_plannable" { + command = plan + + module { + source = "./tests/fixtures/computed-runner-inputs" + } + + assert { + condition = output.runner_stack_keys == ["linux"] + error_message = "Apply-time values inside a statically keyed runner lane must not make binary-syncer module instances unknown." + } + + assert { + condition = output.binaries_syncer_keys == [] + error_message = "A lane with the binary syncer disabled must not create a binary-syncer module instance." + } +} diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf new file mode 100644 index 0000000000..d4174101bb --- /dev/null +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -0,0 +1,62 @@ +# Keep the runner lane key and provider selection caller-known while passing an +# apply-time value through the lane, matching callers that attach a policy +# created in the same plan. +resource "random_id" "managed_policy" { + byte_length = 4 +} + +module "multi_runner" { + source = "../../.." + + aws_region = "eu-west-1" + prefix = "computed-inputs" + 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" + syncer_lambda_s3_key = "runner-binaries-syncer.zip" + + experimental = { + multi_runner_config_v2 = { + linux = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + iam = { + managed_policy_arns = { + generated = "arn:aws:iam::123456789012:policy/generated-${random_id.managed_policy.hex}" + } + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } +} + +output "runner_stack_keys" { + value = keys(module.multi_runner.runners_map_v2) +} + +output "binaries_syncer_keys" { + value = keys(module.multi_runner.binaries_syncer_map) +} diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/versions.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/versions.tf new file mode 100644 index 0000000000..9fd85fad8f --- /dev/null +++ b/modules/multi-runner/tests/fixtures/computed-runner-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" + } + } +} From 9bcca0d0ff32f797d5f678467b8716e4604b38af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:36:10 +0000 Subject: [PATCH 11/49] docs: auto update terraform docs --- .../fixtures/computed-runner-inputs/README.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md new file mode 100644 index 0000000000..5695a15af3 --- /dev/null +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -0,0 +1,37 @@ + +## 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 | +|------|--------|---------| +| [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | + +## Resources + +| Name | Type | +|------|------| +| [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +No inputs. + +## Outputs + +| Name | Description | +|------|-------------| +| [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | +| [runner\_stack\_keys](#output\_runner\_stack\_keys) | n/a | + \ No newline at end of file From e46764298bd92eab38be3713c44803de4aa020d5 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 03:19:49 +0200 Subject: [PATCH 12/49] feat(termination-watcher): support encrypted app parameters --- modules/termination-watcher/README.md | 14 +++++++------- modules/termination-watcher/deregister-retry.tf | 5 +++++ modules/termination-watcher/main.tf | 8 ++------ modules/termination-watcher/notification/main.tf | 5 +++++ modules/termination-watcher/termination/main.tf | 5 +++++ modules/termination-watcher/variables.tf | 2 ++ 6 files changed, 26 insertions(+), 13 deletions(-) diff --git a/modules/termination-watcher/README.md b/modules/termination-watcher/README.md index 4cdf37f13b..f52640b4bc 100644 --- a/modules/termination-watcher/README.md +++ b/modules/termination-watcher/README.md @@ -59,20 +59,20 @@ yarn run dist ## 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 | -|------|--------|---------| +| ---- | ------ | ------- | | [deregister\_retry\_lambda](#module\_deregister\_retry\_lambda) | ../lambda | n/a | | [termination\_handler](#module\_termination\_handler) | ./termination | n/a | | [termination\_notification](#module\_termination\_notification) | ./notification | n/a | @@ -80,7 +80,7 @@ yarn run dist ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_iam_role_policy.deregister_retry_ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.deregister_retry_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.deregister_retry_ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -93,13 +93,13 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration for the spot termination watcher.

`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.
'features': Features to enable the different lambda functions to handle spot termination events.
`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'.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `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.
`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`.
`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.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`github_app_parameters`: GitHub App SSM parameters (`id` and `key_base64`, each a map of `arn`/`name`) used to authenticate to GitHub when deregistering runners.
`ghes_url`: GitHub Enterprise Server URL used to target the GHES API when deregistering runners. Leave `null` for github.com. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
environment_variables = optional(map(string), {})
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
lambda_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)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_spot_termination = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
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, null)
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), [])
tag_filters = optional(map(string), null)
tags = optional(map(string), {})
timeout = optional(number, null)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
enable_runner_deregistration = optional(bool, false)
github_app_parameters = optional(object({
id = map(string)
key_base64 = map(string)
}), null)
ghes_url = optional(string, null)
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Configuration for the spot termination watcher.

`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.
'features': Features to enable the different lambda functions to handle spot termination events.
`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'.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`ssm_kms_key_id`: Optional KMS key ARN used to decrypt GitHub App parameters while deregistering runners. The ARN may be unknown until apply.
`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.
`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`.
`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.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`github_app_parameters`: GitHub App SSM parameters (`id` and `key_base64`, each a map of `arn`/`name`) used to authenticate to GitHub when deregistering runners.
`ghes_url`: GitHub Enterprise Server URL used to target the GHES API when deregistering runners. Leave `null` for github.com. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
environment_variables = optional(map(string), {})
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
ssm_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, "GitHub Runners")
metric = optional(object({
enable_spot_termination = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
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, null)
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), [])
tag_filters = optional(map(string), null)
tags = optional(map(string), {})
timeout = optional(number, null)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
enable_runner_deregistration = optional(bool, false)
github_app_parameters = optional(object({
id = map(string)
key_base64 = map(string)
}), null)
ghes_url = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [deregister\_retry](#output\_deregister\_retry) | n/a | | [spot\_termination\_handler](#output\_spot\_termination\_handler) | n/a | | [spot\_termination\_notification](#output\_spot\_termination\_notification) | n/a | diff --git a/modules/termination-watcher/deregister-retry.tf b/modules/termination-watcher/deregister-retry.tf index 921d7abf5e..a856274b0f 100644 --- a/modules/termination-watcher/deregister-retry.tf +++ b/modules/termination-watcher/deregister-retry.tf @@ -101,6 +101,11 @@ resource "aws_iam_role_policy" "deregister_retry_ssm" { Effect = "Allow" Action = ["ssm:GetParameter"] Resource = local.ssm_parameter_arns + }, + { + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = [local.config._ssm_kms_key_id] } ] }) diff --git a/modules/termination-watcher/main.tf b/modules/termination-watcher/main.tf index 919ba3a3e5..145e19380b 100644 --- a/modules/termination-watcher/main.tf +++ b/modules/termination-watcher/main.tf @@ -18,19 +18,15 @@ locals { var.config.github_app_parameters.key_base64.arn, ] : [] - environment_variables = { - ENABLE_METRICS_SPOT_WARNING = var.config.metrics != null ? var.config.metrics.enable && var.config.metrics.metric.enable_spot_termination_warning : false - TAG_FILTERS = jsonencode(var.config.tag_filters) - } - config = merge(var.config, { name = local.name, handler = "index.interruptionWarning", zip = local.lambda_zip, - environment_variables = local.environment_variables + environment_variables = var.config.environment_variables metrics_namespace = var.config.metrics.namespace _deregistration_env_vars = local.deregistration_env_vars _ssm_parameter_arns = local.ssm_parameter_arns + _ssm_kms_key_id = coalesce(var.config.ssm_kms_key_id, "arn:${coalesce(var.config.aws_partition, "aws")}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000") _enable_runner_deregistration = local.enable_runner_deregistration }) } diff --git a/modules/termination-watcher/notification/main.tf b/modules/termination-watcher/notification/main.tf index 735c34126b..7683a760f9 100644 --- a/modules/termination-watcher/notification/main.tf +++ b/modules/termination-watcher/notification/main.tf @@ -102,6 +102,11 @@ resource "aws_iam_role_policy" "ssm_policy" { Effect = "Allow" Action = ["ssm:GetParameter"] Resource = var.config._ssm_parameter_arns + }, + { + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = [var.config._ssm_kms_key_id] } ] }) diff --git a/modules/termination-watcher/termination/main.tf b/modules/termination-watcher/termination/main.tf index f43b61775a..d96e6f820e 100644 --- a/modules/termination-watcher/termination/main.tf +++ b/modules/termination-watcher/termination/main.tf @@ -66,6 +66,11 @@ resource "aws_iam_role_policy" "ssm_policy" { Effect = "Allow" Action = ["ssm:GetParameter"] Resource = var.config._ssm_parameter_arns + }, + { + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = [var.config._ssm_kms_key_id] } ] }) diff --git a/modules/termination-watcher/variables.tf b/modules/termination-watcher/variables.tf index a72bf74916..4d60abc5e8 100644 --- a/modules/termination-watcher/variables.tf +++ b/modules/termination-watcher/variables.tf @@ -11,6 +11,7 @@ variable "config" { `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with + `ssm_kms_key_id`: Optional KMS key ARN used to decrypt GitHub App parameters while deregistering runners. The ARN may be unknown until apply. `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. `prefix`: The prefix used for naming resources. @@ -43,6 +44,7 @@ variable "config" { log_level = optional(string, null) log_class = optional(string, "STANDARD") logging_kms_key_id = optional(string, null) + ssm_kms_key_id = optional(string, null) logging_retention_in_days = optional(number, null) memory_size = optional(number, null) metrics = optional(object({ From 6b029e6b028a03902ce01aee8e728176a27f04a5 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 03:47:59 +0200 Subject: [PATCH 13/49] feat(multi-runner): add canonical experimental configuration --- modules/multi-runner/README.md | 69 +- modules/multi-runner/ami-housekeeper.tf | 46 +- modules/multi-runner/compute-provider.tf | 4 +- modules/multi-runner/config.experimental.tf | 178 - .../config.experimental.translation.tf | 763 ++++ modules/multi-runner/main.tf | 23 +- modules/multi-runner/outputs.tf | 8 +- modules/multi-runner/queues.tf | 45 +- modules/multi-runner/runner-binaries.tf | 68 +- modules/multi-runner/runners.experimental.tf | 219 +- modules/multi-runner/runners.tf | 269 +- modules/multi-runner/ssm.tf | 17 +- modules/multi-runner/termination-watcher.tf | 61 +- .../fixtures/computed-runner-inputs/README.md | 10 +- .../fixtures/computed-runner-inputs/main.tf | 57 +- .../tests/provider-routing.tftest.hcl | 3948 ++++++++++++++++- .../multi-runner/validations.experimental.tf | 349 ++ .../multi-runner/variables.experimental.tf | 1142 ++++- modules/multi-runner/variables.tf | 10 +- modules/multi-runner/versions.tf | 2 +- modules/multi-runner/webhook.tf | 56 +- modules/runner-stack/README.md | 26 +- modules/runner-stack/common-config.tf | 2 +- modules/runner-stack/job-retry.tf | 4 +- modules/runner-stack/job-retry/README.md | 14 +- .../runner-stack/job-retry/iam-policies.tf | 23 +- modules/runner-stack/job-retry/job-retry.tf | 1 + .../job-retry/tests/job-retry.tftest.hcl | 20 +- modules/runner-stack/job-retry/variables.tf | 10 +- modules/runner-stack/pool.tf | 3 +- modules/runner-stack/pool/README.md | 12 +- modules/runner-stack/pool/iam-policies.tf | 16 +- modules/runner-stack/pool/pool.tf | 9 + .../pool/tests/provider.tftest.hcl | 27 +- modules/runner-stack/pool/variables.tf | 16 +- modules/runner-stack/scale-runners.tf | 3 +- modules/runner-stack/scale-runners/README.md | 14 +- .../scale-runners/lambda-iam-policies.tf | 9 + .../scale-runners/scale-down-iam-policies.tf | 15 +- .../scale-runners/scale-up-iam-policies.tf | 15 +- .../tests/scale-runners.tftest.hcl | 18 +- .../runner-stack/scale-runners/variables.tf | 11 +- modules/runner-stack/ssm-housekeeper.tf | 1 + .../runner-stack/ssm-housekeeper/README.md | 12 +- .../ssm-housekeeper/iam-policies.tf | 9 + .../tests/ssm-housekeeper.tftest.hcl | 12 + .../runner-stack/ssm-housekeeper/variables.tf | 5 + .../computed-iam-inputs.tf | 4 +- modules/runner-stack/variables.tf | 14 +- 49 files changed, 6429 insertions(+), 1240 deletions(-) delete mode 100644 modules/multi-runner/config.experimental.tf create mode 100644 modules/multi-runner/config.experimental.translation.tf create mode 100644 modules/multi-runner/validations.experimental.tf diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 70d942ca39..ae2ab09426 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -6,9 +6,10 @@ This module creates many runners with one or more GitHub Apps. The module utiliz ### GitHub App round-robin -To distribute GitHub API rate limit usage, this module supports configuring multiple GitHub Apps via the `additional_github_apps` variable. The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly select an app for each API call, spreading the load across all configured apps. +To distribute GitHub API rate limit usage, this module supports configuring multiple GitHub Apps. Stable v1 uses `additional_github_apps`; v2 uses the authoritative `experimental.github.additional_apps`, which defaults to `[]`. The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly select an app for each API call, spreading the load across all configured apps. + +The **primary app** (`github_app` in v1 and `experimental.github.app` in v2) is special: -The **primary app** (`github_app`) is special: - Its **webhook secret** is used to validate incoming GitHub webhook payloads. Only the primary app needs a webhook URL configured in GitHub. - Its **app ID and private key** are included in the round-robin pool alongside the additional apps. @@ -24,31 +25,47 @@ 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. -To opt into v2, leave `multi_runner_config` empty and populate `experimental.multi_runner_config_v2`. The whole module instance then uses `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 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. +A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-stack` at `module.runner_stacks["configuration"]`. Sibling `experimental.tags`, `roles`, `runner`, `github`, `enterprise_server`, `user_agent`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each lane can override supported lane-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides affect only that lane, never a singleton shared component. A nullable lane field inherits its global value. Within v2 queue and runner-stack scopes, tags merge from global to lane and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. + +The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental lane map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, global/lane precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-stack GitHub client settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable lanes are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_stacks` call directly iterates the gated final lanes, inlines the environment tag and live GitHub App and build-queue references, exposes the scale-up, scale-down, and pool aliases expected by `runner-stack`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. + +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner stacks; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.enterprise_server.url` defaults to `null` and configures both v2 runner-stack GitHub clients and the shared termination watcher. `experimental.enterprise_server.ssl_verify` and `experimental.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. + +Shared singleton resources use the translated global contract without accepting per-lane overrides. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Runner-stack control-plane artifacts come from global `experimental.lambda.scale.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources unselected uses the packaged runner archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves legacy S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. Root `experimental.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier. `lambda.webhook` owns webhook artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. + +Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for lane-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the lane key only to lane roots. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every runner stack. It does not select encryption for runtime-created lane runner parameters. IAM consumers retain a static statement with a harmless sentinel resource when the value is null, so the configured ARN may remain unknown until apply. + +The 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. Each lane still selects its provider from exactly one populated typed block under its own `compute_provider`; the global provider block supplies v2 defaults only. The same wrapper reaches runner-stack, whose direct input contract also validates exactly one populated provider block before dispatch. The EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. The selected lane 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. -Phase 1 supports both input contracts, but callers must populate only one runner configuration map per module instance. When `experimental.multi_runner_config_v2` is empty, `multi_runner_config` follows the unchanged legacy path. To use v2, `multi_runner_config` must be empty and `experimental.multi_runner_config_v2` becomes the complete runner map. The maps are not merged, and populating both is unsupported. +Phase 1 supports both input contracts with deterministic precedence. When `experimental.multi_runner_config` is empty, the stable top-level `multi_runner_config` follows the unchanged legacy path. When the experimental map is non-empty, it becomes the complete runner map and stable entries are ignored. The maps are not merged. + +Global `experimental.queue` owns the v2 defaults for build-queue delay, retention, visibility, redrive, tags, and encryption. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. Redrive defaults to disabled with a null `maxReceiveCount`; a null lane wrapper or leaf inherits the matching global value, and an enabled result requires `maxReceiveCount` greater than zero. Lane fields under `multi_runner_config[].queue` override the corresponding global queue defaults and lane tags merge over global queue tags. Encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. When supplying the block explicitly, provide all three leaves and use null for inactive KMS or SQS-managed settings. This block configures the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues. Its CMK is independent from `experimental.ssm.kms_key_id`; current v2 webhook, scale-up, and job-retry role policies do not derive grants from a distinct queue CMK, so callers must grant those roles the required key permissions. + +For v2, `multi_runner_config[].queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].lambda.scale_up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. + +Global `experimental.compute_provider.ec2.runner_binaries` owns the shared distribution-bucket and syncer defaults. It covers lane enablement, bucket encryption, tags, versioning and access logging, plus the syncer artifact, Lambda sizing, and schedule. `runner_binaries.enabled` defaults to `true`, while a nullable lane `compute_provider.ec2.binaries_syncer.enabled` overrides that default; enabled distributions are created once per unique operating-system and architecture pair. The resolved enable value, `s3.encryption.enabled`, the nullness of `s3.encryption.kms_master_key_id`, and the nullness of `s3.logging.bucket` must be known during planning because they determine module or resource shape. A distribution CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from it; attach that permission separately when using a CMK. ### 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. +For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `queue.tags`, and lane-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `lambda.scale_up.tags`, `lambda.scale_down.tags`, `lambda.webhook.tags`, `lambda.pool.tags`, `job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain lane-owned runner-stack log-group tags. In stable mode, translation preserves the existing flat behavior. 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. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. ### 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. +Here, v1 and v2 refer to the stable top-level `multi_runner_config` and `experimental.multi_runner_config`, 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 as a module-level opt-in (current) -Both input contracts are available in the same module release, but callers must populate only one 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. To select `module.runner_stacks["configuration"]` and the nested `runners_map_v2` output shape, leave `multi_runner_config` empty and populate the v2 map. Populating both maps is unsupported. +Both input contracts are available in the same module release. An empty `experimental.multi_runner_config` keeps every stable top-level `multi_runner_config` entry on the existing `modules/runners` implementation at `module.runners["configuration"]`, retaining its flat `runners_map` output and Terraform addresses. Internally, the flat input is projected through `local.translated_experimental_base` and finalized as `local.translated_experimental`; `runners.tf` adapts those canonical lanes to the existing module call instead of forwarding the original v1 object. A non-empty experimental map selects `module.runner_stacks["configuration"]` and the nested `runners_map_v2` output shape; it takes priority over stable entries, and the maps are not combined. -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. +Compatibility guarantee: upgrading while leaving `experimental.multi_runner_config` 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`. 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. +`multi_runner_config` remains accepted but is deprecated, and its existing translated representation becomes the dispatch source for `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. @@ -64,7 +81,7 @@ Removing `modules/runners` is a separate future change. It requires its own comp 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. +- When globally enabled or enabled by a lane override, 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. - For each configuration a queue is created and [runner module](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/runners/) is deployed ## Matching @@ -142,22 +159,23 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.3 | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [random](#provider\_random) | ~> 3.0 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | +| [random](#provider\_random) | 3.9.0 | +| [terraform](#provider\_terraform) | n/a | ## 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 | @@ -169,18 +187,19 @@ 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 | | [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | +| [terraform_data.validate_experimental](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | 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 | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -196,7 +215,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. To opt into v2, leave `multi_runner_config` empty and populate this map. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module. Populating both maps in the same module instance is unsupported.

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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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.
- `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), {})
additional_trust_policy_json = optional(string, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`.
- `user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`.
- `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive.
- `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK.
- `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].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.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(number, null)
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
}), {})

enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})

user_agent = optional(string, "github-aws-runners")

webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

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

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), 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({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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 | @@ -204,13 +223,13 @@ module "multi-runner" { | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`environment_variables`: Additional environment variables to merge into the Lambda configuration.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no | | [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 | +| [kms\_key\_arn](#input\_kms\_key\_arn) | Stable/v1 optional KMS key ARN for Parameter Store. Experimental v2 uses `experimental.ssm.kms_key_id`; this flat value only seeds stable-mode translation. | `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\_principals](#input\_lambda\_principals) | (Optional) add extra principals to the role created for execution of the lambda, e.g. for local testing. |
list(object({
type = string
identifiers = list(string)
}))
| `[]` | 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\_s3\_bucket](#input\_lambda\_s3\_bucket) | Stable/v1 S3 bucket containing Lambda artifacts. A non-null value takes precedence over flat local-zip inputs during stable-mode translation. Experimental v2 uses the shared `experimental.lambda.artifact.s3.bucket`. | `string` | `null` | 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 action runners 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 | @@ -238,9 +257,9 @@ module "multi-runner" { | [runner\_binaries\_syncer\_lambda\_zip](#input\_runner\_binaries\_syncer\_lambda\_zip) | File location of the binaries sync lambda zip file. | `string` | `null` | no | | [runner\_binaries\_syncer\_memory\_size](#input\_runner\_binaries\_syncer\_memory\_size) | Memory size limit in MB for binary syncer lambda. | `number` | `256` | no | | [runner\_egress\_rules](#input\_runner\_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 | -| [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\_lambda\_zip](#input\_runners\_lambda\_zip) | File location of the lambda zip file for scaling runners. | `string` | `null` | no | +| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | Stable/v1 S3 key for the scaling control-plane Lambda archive. Used when `lambda_s3_bucket` is set. Experimental v2 uses `experimental.lambda.scale.artifact.s3.key`. | `string` | `null` | no | +| [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | Stable/v1 optional object version for the scaling control-plane Lambda archive. Experimental v2 uses `experimental.lambda.scale.artifact.s3.object_version`. | `string` | `null` | no | +| [runners\_lambda\_zip](#input\_runners\_lambda\_zip) | Stable/v1 local scaling control-plane Lambda archive. A configured `lambda_s3_bucket` takes precedence. Experimental v2 uses `experimental.lambda.scale.artifact.zip`. | `string` | `null` | no | | [runners\_scale\_down\_lambda\_timeout](#input\_runners\_scale\_down\_lambda\_timeout) | Time out for the scale down lambda in seconds. | `number` | `60` | no | | [runners\_scale\_up\_lambda\_timeout](#input\_runners\_scale\_up\_lambda\_timeout) | Time out for the scale up lambda in seconds. | `number` | `30` | no | | [runners\_ssm\_housekeeper](#input\_runners\_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.
`enabled`: enable or disable the lambda trigger via the EventBridge.
`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)")
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 | @@ -265,7 +284,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/ami-housekeeper.tf b/modules/multi-runner/ami-housekeeper.tf index 385e6010c9..89365d275d 100644 --- a/modules/multi-runner/ami-housekeeper.tf +++ b/modules/multi-runner/ami-housekeeper.tf @@ -1,35 +1,35 @@ module "ami_housekeeper" { - count = var.enable_ami_housekeeper ? 1 : 0 + count = try(local.translated_experimental.compute_provider.ec2.ami.housekeeper.enabled, false) ? 1 : 0 source = "../ami-housekeeper" prefix = var.prefix - tags = local.tags + tags = merge(local.translated_experimental.tags, { "ghr:environment" = var.prefix }) aws_partition = var.aws_partition - lambda_zip = var.ami_housekeeper_lambda_zip - lambda_s3_bucket = var.lambda_s3_bucket - lambda_s3_key = var.ami_housekeeper_lambda_s3_key - lambda_s3_object_version = var.ami_housekeeper_lambda_s3_object_version + lambda_zip = local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.zip + lambda_s3_bucket = local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + lambda_s3_key = try(local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key, null) + lambda_s3_object_version = try(local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.object_version, null) - lambda_architecture = var.lambda_architecture - lambda_principals = var.lambda_principals - lambda_runtime = var.lambda_runtime - lambda_security_group_ids = var.lambda_security_group_ids - lambda_subnet_ids = var.lambda_subnet_ids - lambda_memory_size = var.ami_housekeeper_lambda_memory_size - lambda_timeout = var.ami_housekeeper_lambda_timeout - lambda_tags = var.lambda_tags - tracing_config = var.tracing_config + lambda_architecture = local.translated_experimental.lambda.architecture + lambda_principals = local.translated_experimental.lambda.principals + lambda_runtime = local.translated_experimental.lambda.runtime + lambda_security_group_ids = local.translated_experimental.lambda.security_group_ids + lambda_subnet_ids = local.translated_experimental.lambda.subnet_ids + lambda_memory_size = local.translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.memory_size + lambda_timeout = local.translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.timeout + lambda_tags = local.translated_experimental.lambda.tags + tracing_config = local.translated_experimental.observability.tracing - 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 + logging_retention_in_days = local.translated_experimental.observability.logs.retention_in_days + logging_kms_key_id = local.translated_experimental.observability.logs.kms_key_id + log_class = local.translated_experimental.observability.logs.class + log_level = local.translated_experimental.observability.logs.level - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary + role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) + role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) - cleanup_config = var.ami_housekeeper_cleanup_config - lambda_schedule_expression = var.ami_housekeeper_lambda_schedule_expression + cleanup_config = local.translated_experimental.compute_provider.ec2.ami.housekeeper.cleanup_config + lambda_schedule_expression = local.translated_experimental.compute_provider.ec2.ami.housekeeper.schedule.expression } diff --git a/modules/multi-runner/compute-provider.tf b/modules/multi-runner/compute-provider.tf index 83793f34f2..5e94067e8c 100644 --- a/modules/multi-runner/compute-provider.tf +++ b/modules/multi-runner/compute-provider.tf @@ -1,6 +1,6 @@ locals { compute_provider_types = { - for runner_key, runner_config in local.multi_runner_config : runner_key => one([ + for runner_key, runner_config in local.translated_experimental_base.multi_runner_config : runner_key => one([ for provider_type, provider_config in runner_config.compute_provider : provider_type if provider_config != null ]) @@ -9,7 +9,7 @@ locals { runner_config_by_provider = { for provider_type in toset(values(local.compute_provider_types)) : provider_type => { - for runner_key, runner_config in local.multi_runner_config : runner_key => runner_config + for runner_key, runner_config in local.translated_experimental_base.multi_runner_config : runner_key => runner_config if local.compute_provider_types[runner_key] == provider_type } } diff --git a/modules/multi-runner/config.experimental.tf b/modules/multi-runner/config.experimental.tf deleted file mode 100644 index 38720b9c51..0000000000 --- a/modules/multi-runner/config.experimental.tf +++ /dev/null @@ -1,178 +0,0 @@ -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 local.selected_multi_runner_config_v1 : k => { - tags = {} - - runner = { - 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 - tags = {} - 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 - } : null - managed_policy_arns = { - 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 - } - - 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 - 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 - 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 = { - 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 - tags = {} - lambda = { - memory_size = v.runner_config.job_retry.lambda_memory_size - timeout = v.runner_config.job_retry.lambda_timeout - reserved_concurrent_executions = 1 - } - } - - ssm = { - tags = {} - kms_key = null - parameters = { - tags = {} - } - housekeeper = { - tags = {} - } - } - - observability = { - logs = { - tags = {} - } - } - - compute_provider = { - ec2 = { - 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 - 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 - 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 - cpu_options = v.runner_config.cpu_options - placement = v.runner_config.placement - license_specifications = v.runner_config.license_specifications - use_dedicated_host = v.runner_config.use_dedicated_host - log_files = v.runner_config.runner_log_files - tags = v.runner_config.runner_ec2_tags - } - } - - matcherConfig = v.matcherConfig - } - } - - # 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 -} diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf new file mode 100644 index 0000000000..3fad9fd3c1 --- /dev/null +++ b/modules/multi-runner/config.experimental.translation.tf @@ -0,0 +1,763 @@ +# Project stable v1 inputs into the experimental schema, then resolve every +# lane against the experimental global defaults. The base object remains +# schema-compatible with var.experimental; the final canonical object adds +# effective lane fields and the resource-backed binary distribution consumed +# by downstream runner modules. +locals { + # A non-empty experimental map is the module-level v2 opt-in. Stable v1 and + # experimental v2 resources must never be selected in the same deployment. + use_multi_runner_config_v2 = length(var.experimental.multi_runner_config) > 0 + + raw_translated_experimental = local.use_multi_runner_config_v2 ? var.experimental : { + tags = var.tags + + roles = { + path = var.role_path + permissions_boundary = var.role_permissions_boundary + } + + runner = { + os = null + architecture = null + boot_time_in_minutes = 5 + disable_default_labels = false + extra_labels = [] + group_name = "Default" + name_prefix = "" + run_as_root = false + run_as = "ec2-user" + maximum_count = null + ephemeral = false + jit_config_enabled = null + auto_update_disabled = false + tags = {} + hooks = { + job_started = "" + job_completed = "" + } + iam = { + role = null + managed_policy_arns = {} + additional_trust_policy_json = null + path = null + permissions_boundary = null + } + } + + github = { + app = var.github_app + additional_apps = var.additional_github_apps + repository_white_list = var.repository_white_list + } + + enterprise_server = { + url = var.ghes_url + ssl_verify = var.ghes_ssl_verify + } + + user_agent = var.user_agent + + webhook = { + queue_selection_strategy = var.queue_selection_strategy + eventbridge = var.eventbridge + matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier + } + + lambda = { + artifact = { + s3 = { + bucket = var.lambda_s3_bucket + } + } + scale = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.runners_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.runners_lambda_s3_key + object_version = var.runners_lambda_s3_object_version + } + } + } + runtime = var.lambda_runtime + architecture = var.lambda_architecture + principals = var.lambda_principals + subnet_ids = var.lambda_subnet_ids + security_group_ids = var.lambda_security_group_ids + tags = var.lambda_tags + role = { + path = null + permissions_boundary = null + } + scale_up = { + memory_size = var.scale_up_lambda_memory_size + timeout = var.runners_scale_up_lambda_timeout + reserved_concurrent_executions = 1 + job_queued_check_enabled = null + event_source_mapping = { + 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 = {} + } + scale_down = { + memory_size = var.scale_down_lambda_memory_size + timeout = var.runners_scale_down_lambda_timeout + schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = null + idle_config = [] + tags = {} + } + webhook = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.webhook_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.webhook_lambda_s3_key + object_version = var.webhook_lambda_s3_object_version + } + } + api_gateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings + memory_size = var.webhook_lambda_memory_size + timeout = var.webhook_lambda_timeout + tags = {} + } + pool = { + memory_size = 512 + timeout = var.pool_lambda_timeout + reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + config = [] + include_busy_runners = false + runner_owner = null + tags = {} + } + } + + queue = { + delay_webhook_event = 30 + job_queue_retention_in_seconds = 86400 + visibility_timeout_seconds = var.runners_scale_up_lambda_timeout + redrive_build_queue = { + enabled = false + maxReceiveCount = null + } + tags = {} + encryption = var.queue_encryption + } + + ssm = { + paths = { + root = "/${var.ssm_paths.root}/${var.prefix}" + app = var.ssm_paths.app + webhook = var.ssm_paths.webhook + tokens = "${var.ssm_paths.runners}/tokens" + config = "${var.ssm_paths.runners}/config" + } + kms_key_id = var.kms_key_arn + tags = {} + parameters = { + tags = var.parameter_store_tags + } + housekeeper = { + schedule_expression = var.runners_ssm_housekeeper.schedule_expression + state = var.runners_ssm_housekeeper.enabled ? "ENABLED" : "DISABLED" + tags = {} + lambda = { + memory_size = var.runners_ssm_housekeeper.lambda_memory_size + timeout = var.runners_ssm_housekeeper.lambda_timeout + } + config = { + tokenPath = var.runners_ssm_housekeeper.config.tokenPath + minimumDaysOld = var.runners_ssm_housekeeper.config.minimumDaysOld + dryRun = var.runners_ssm_housekeeper.config.dryRun + } + } + } + + 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 = {} + } + tracing = var.tracing_config + metrics = { + enable = var.metrics.enable + namespace = var.metrics.namespace + metric = { + enable_github_app_rate_limit = var.metrics.metric.enable_github_app_rate_limit + enable_job_retry = var.metrics.metric.enable_job_retry + enable_spot_termination = true + enable_spot_termination_warning = var.metrics.metric.enable_spot_termination_warning + } + } + } + + compute_provider = { + ec2 = { + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + managed_security_group_enabled = var.enable_managed_runner_security_group + egress_rules = var.runner_egress_rules + additional_security_group_ids = var.runner_additional_security_group_ids + cloudwatch_agent = { + config = var.cloudwatch_config + } + instance_profile_path = var.instance_profile_path + key_name = var.key_name + associate_public_ipv4_address = var.associate_public_ipv4_address + tags = {} + ami = { + housekeeper = { + enabled = var.enable_ami_housekeeper + cleanup_config = var.ami_housekeeper_cleanup_config + artifact = { + zip = var.lambda_s3_bucket == null ? var.ami_housekeeper_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.ami_housekeeper_lambda_s3_key + object_version = var.ami_housekeeper_lambda_s3_object_version + } + } + lambda = { + memory_size = var.ami_housekeeper_lambda_memory_size + timeout = var.ami_housekeeper_lambda_timeout + } + schedule = { + expression = var.ami_housekeeper_lambda_schedule_expression + } + } + } + instance_termination_watcher = { + enabled = var.instance_termination_watcher.enable + features = var.instance_termination_watcher.features + enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration + environment_variables = var.instance_termination_watcher.environment_variables + artifact = { + zip = var.lambda_s3_bucket == null ? var.instance_termination_watcher.zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.instance_termination_watcher.s3_key + object_version = var.instance_termination_watcher.s3_object_version + } + } + lambda = { + memory_size = var.instance_termination_watcher.memory_size + timeout = var.instance_termination_watcher.timeout + } + } + runner_binaries = { + enabled = true + s3 = { + encryption = { + enabled = var.runner_binaries_s3_sse_configuration != null + bucket_key_enabled = try(var.runner_binaries_s3_sse_configuration.rule.bucket_key_enabled, null) + sse_algorithm = try(var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm, "AES256") + kms_master_key_id = try(var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.kms_master_key_id, null) + } + tags = var.runner_binaries_s3_tags + versioning = var.runner_binaries_s3_versioning + logging = { + bucket = null + prefix = null + } + } + syncer = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.runner_binaries_syncer_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.syncer_lambda_s3_key + object_version = var.syncer_lambda_s3_object_version + } + } + lambda = { + memory_size = var.runner_binaries_syncer_memory_size + timeout = var.runner_binaries_syncer_lambda_timeout + } + schedule = { + expression = "cron(27 * * * ? *)" + state = var.state_event_rule_binaries_syncer + } + } + } + } + } + + multi_runner_config = { + for k, v in var.multi_runner_config : k => { + tags = {} + + runner = { + 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 + tags = {} + 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 + } : null + managed_policy_arns = { + for policy_index, policy_arn in v.runner_config.runner_iam_role_managed_policy_arns : + "legacy-${policy_index}" => policy_arn + } + additional_trust_policy_json = null + path = null + permissions_boundary = null + } + } + + github = { + organization_runners = v.runner_config.enable_organization_runners + } + + lambda = { + runtime = null + architecture = null + subnet_ids = null + security_group_ids = null + tags = {} + role = { + path = null + permissions_boundary = null + } + scale_up = { + memory_size = null + timeout = null + reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions + job_queued_check_enabled = v.runner_config.enable_job_queued_check + 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 + } + tags = {} + } + scale_down = { + memory_size = null + timeout = null + 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 = { + memory_size = null + timeout = null + reserved_concurrent_executions = null + config = v.runner_config.pool_config + include_busy_runners = false + runner_owner = v.runner_config.pool_runner_owner + tags = {} + } + } + + queue = { + delay_webhook_event = v.runner_config.delay_webhook_event + job_queue_retention_in_seconds = v.runner_config.job_queue_retention_in_seconds + visibility_timeout_seconds = var.runners_scale_up_lambda_timeout + redrive_build_queue = v.redrive_build_queue + tags = {} + } + + 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 + tags = {} + lambda = { + memory_size = v.runner_config.job_retry.lambda_memory_size + reserved_concurrent_executions = 1 + timeout = v.runner_config.job_retry.lambda_timeout + } + } + + ssm = { + paths = { + root = null + tokens = null + config = null + } + tags = {} + parameters = { + tags = {} + } + housekeeper = { + schedule_expression = null + state = null + tags = {} + lambda = { + memory_size = null + timeout = null + } + config = { + tokenPath = null + minimumDaysOld = null + dryRun = null + } + } + } + + observability = { + logs = { + level = null + retention_in_days = null + kms_key_id = null + class = null + tags = {} + } + tracing = { + mode = null + capture_http_requests = null + capture_error = null + } + metrics = { + enable = null + namespace = null + metric = { + enable_github_app_rate_limit = null + enable_job_retry = null + } + } + } + + compute_provider = { + ec2 = { + metadata_options = { + instance_metadata_tags = tostring(v.runner_config.runner_metadata_options["instance_metadata_tags"]) + http_endpoint = tostring(v.runner_config.runner_metadata_options["http_endpoint"]) + http_tokens = tostring(v.runner_config.runner_metadata_options["http_tokens"]) + http_put_response_hop_limit = tonumber(v.runner_config.runner_metadata_options["http_put_response_hop_limit"]) + } + 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 + 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 = length(v.runner_config.runner_additional_security_group_ids) == 0 ? null : v.runner_config.runner_additional_security_group_ids + managed_security_group_enabled = null + egress_rules = null + instance_profile_path = null + key_name = null + associate_public_ipv4_address = null + instance_profile = v.runner_config.iam_overrides.override_instance_profile == true ? { + name = v.runner_config.iam_overrides.instance_profile_name + } : null + 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 + cpu_options = v.runner_config.cpu_options + placement = v.runner_config.placement + license_specifications = v.runner_config.license_specifications + use_dedicated_host = v.runner_config.use_dedicated_host + log_files = v.runner_config.runner_log_files + tags = v.runner_config.runner_ec2_tags + } + } + + matcherConfig = v.matcherConfig + } + } + } +} + +locals { + translated_experimental_base = merge(local.raw_translated_experimental, { + multi_runner_config = { + for k, v in local.raw_translated_experimental.multi_runner_config : k => merge(v, { + tags = merge(local.raw_translated_experimental.tags, v.tags) + + runner = merge(v.runner, { + os = try(coalesce(v.runner.os, local.raw_translated_experimental.runner.os), null) + architecture = try(coalesce(v.runner.architecture, local.raw_translated_experimental.runner.architecture), null) + boot_time_in_minutes = coalesce(v.runner.boot_time_in_minutes, local.raw_translated_experimental.runner.boot_time_in_minutes) + disable_default_labels = coalesce(v.runner.disable_default_labels, local.raw_translated_experimental.runner.disable_default_labels) + extra_labels = v.runner.extra_labels != null ? v.runner.extra_labels : local.raw_translated_experimental.runner.extra_labels + group_name = coalesce(v.runner.group_name, local.raw_translated_experimental.runner.group_name) + name_prefix = v.runner.name_prefix != null ? v.runner.name_prefix : local.raw_translated_experimental.runner.name_prefix + run_as_root = coalesce(v.runner.run_as_root, local.raw_translated_experimental.runner.run_as_root) + run_as = coalesce(v.runner.run_as, local.raw_translated_experimental.runner.run_as) + maximum_count = try(coalesce(v.runner.maximum_count, local.raw_translated_experimental.runner.maximum_count), null) + ephemeral = coalesce(v.runner.ephemeral, local.raw_translated_experimental.runner.ephemeral) + jit_config_enabled = try(coalesce(v.runner.jit_config_enabled, local.raw_translated_experimental.runner.jit_config_enabled), null) + auto_update_disabled = coalesce(v.runner.auto_update_disabled, local.raw_translated_experimental.runner.auto_update_disabled) + tags = merge(local.raw_translated_experimental.runner.tags, v.runner.tags) + hooks = { + job_started = v.runner.hooks.job_started != null ? v.runner.hooks.job_started : local.raw_translated_experimental.runner.hooks.job_started + job_completed = v.runner.hooks.job_completed != null ? v.runner.hooks.job_completed : local.raw_translated_experimental.runner.hooks.job_completed + } + iam = { + role = try(coalesce(v.runner.iam.role, local.raw_translated_experimental.runner.iam.role), null) + managed_policy_arns = v.runner.iam.role != null ? ( + v.runner.iam.managed_policy_arns != null ? v.runner.iam.managed_policy_arns : {} + ) : ( + v.runner.iam.managed_policy_arns != null ? v.runner.iam.managed_policy_arns : local.raw_translated_experimental.runner.iam.managed_policy_arns + ) + additional_trust_policy_json = v.runner.iam.role != null ? v.runner.iam.additional_trust_policy_json : try(coalesce(v.runner.iam.additional_trust_policy_json, local.raw_translated_experimental.runner.iam.additional_trust_policy_json), null) + path = try(coalesce(v.runner.iam.path, local.raw_translated_experimental.runner.iam.path, local.raw_translated_experimental.roles.path), null) + permissions_boundary = try(coalesce(v.runner.iam.permissions_boundary, local.raw_translated_experimental.runner.iam.permissions_boundary, local.raw_translated_experimental.roles.permissions_boundary), null) + } + }) + + lambda = merge(v.lambda, { + runtime = coalesce(v.lambda.runtime, local.raw_translated_experimental.lambda.runtime) + architecture = coalesce(v.lambda.architecture, local.raw_translated_experimental.lambda.architecture) + subnet_ids = v.lambda.subnet_ids != null ? v.lambda.subnet_ids : local.raw_translated_experimental.lambda.subnet_ids + security_group_ids = v.lambda.security_group_ids != null ? v.lambda.security_group_ids : local.raw_translated_experimental.lambda.security_group_ids + tags = merge(local.raw_translated_experimental.lambda.tags, v.lambda.tags) + role = { + path = try(coalesce( + v.lambda.role.path, + local.raw_translated_experimental.lambda.role.path, + local.raw_translated_experimental.roles.path, + ), null) + permissions_boundary = try(coalesce( + v.lambda.role.permissions_boundary, + local.raw_translated_experimental.lambda.role.permissions_boundary, + local.raw_translated_experimental.roles.permissions_boundary, + ), null) + } + scale_up = merge(v.lambda.scale_up, { + memory_size = coalesce(v.lambda.scale_up.memory_size, local.raw_translated_experimental.lambda.scale_up.memory_size) + timeout = coalesce(v.lambda.scale_up.timeout, local.raw_translated_experimental.lambda.scale_up.timeout) + reserved_concurrent_executions = coalesce(v.lambda.scale_up.reserved_concurrent_executions, local.raw_translated_experimental.lambda.scale_up.reserved_concurrent_executions) + job_queued_check_enabled = try(coalesce(v.lambda.scale_up.job_queued_check_enabled, local.raw_translated_experimental.lambda.scale_up.job_queued_check_enabled), null) + event_source_mapping = { + batch_size = coalesce( + v.lambda.scale_up.event_source_mapping.batch_size, + local.raw_translated_experimental.lambda.scale_up.event_source_mapping.batch_size, + ) + maximum_batching_window_in_seconds = coalesce( + v.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, + local.raw_translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, + ) + } + tags = merge(local.raw_translated_experimental.lambda.scale_up.tags, v.lambda.scale_up.tags) + }) + scale_down = merge(v.lambda.scale_down, { + memory_size = coalesce(v.lambda.scale_down.memory_size, local.raw_translated_experimental.lambda.scale_down.memory_size) + timeout = coalesce(v.lambda.scale_down.timeout, local.raw_translated_experimental.lambda.scale_down.timeout) + schedule_expression = coalesce(v.lambda.scale_down.schedule_expression, local.raw_translated_experimental.lambda.scale_down.schedule_expression) + minimum_running_time_in_minutes = try(coalesce(v.lambda.scale_down.minimum_running_time_in_minutes, local.raw_translated_experimental.lambda.scale_down.minimum_running_time_in_minutes), null) + idle_config = v.lambda.scale_down.idle_config != null ? v.lambda.scale_down.idle_config : local.raw_translated_experimental.lambda.scale_down.idle_config + tags = merge(local.raw_translated_experimental.lambda.scale_down.tags, v.lambda.scale_down.tags) + }) + pool = merge(v.lambda.pool, { + memory_size = coalesce(v.lambda.pool.memory_size, local.raw_translated_experimental.lambda.pool.memory_size) + timeout = coalesce(v.lambda.pool.timeout, local.raw_translated_experimental.lambda.pool.timeout) + reserved_concurrent_executions = coalesce(v.lambda.pool.reserved_concurrent_executions, local.raw_translated_experimental.lambda.pool.reserved_concurrent_executions) + config = v.lambda.pool.config != null ? v.lambda.pool.config : local.raw_translated_experimental.lambda.pool.config + include_busy_runners = coalesce(v.lambda.pool.include_busy_runners, local.raw_translated_experimental.lambda.pool.include_busy_runners) + runner_owner = try(coalesce(v.lambda.pool.runner_owner, local.raw_translated_experimental.lambda.pool.runner_owner), null) + tags = merge(local.raw_translated_experimental.lambda.pool.tags, v.lambda.pool.tags) + }) + }) + + queue = merge(v.queue, { + delay_webhook_event = coalesce(v.queue.delay_webhook_event, local.raw_translated_experimental.queue.delay_webhook_event) + job_queue_retention_in_seconds = coalesce(v.queue.job_queue_retention_in_seconds, local.raw_translated_experimental.queue.job_queue_retention_in_seconds) + visibility_timeout_seconds = coalesce(v.queue.visibility_timeout_seconds, local.raw_translated_experimental.queue.visibility_timeout_seconds) + redrive_build_queue = { + enabled = try( + coalesce(try(v.queue.redrive_build_queue.enabled, null), local.raw_translated_experimental.queue.redrive_build_queue.enabled), + local.raw_translated_experimental.queue.redrive_build_queue.enabled, + ) + maxReceiveCount = try( + coalesce(try(v.queue.redrive_build_queue.maxReceiveCount, null), local.raw_translated_experimental.queue.redrive_build_queue.maxReceiveCount), + null, + ) + } + tags = merge(local.raw_translated_experimental.queue.tags, v.queue.tags) + }) + + ssm = merge(v.ssm, { + paths = { + root = "${trimsuffix(coalesce( + v.ssm.paths.root, + local.raw_translated_experimental.ssm.paths.root, + "/github-action-runners/${var.prefix}", + ), "/")}/${k}" + tokens = coalesce(v.ssm.paths.tokens, local.raw_translated_experimental.ssm.paths.tokens) + config = coalesce(v.ssm.paths.config, local.raw_translated_experimental.ssm.paths.config) + } + tags = merge(local.raw_translated_experimental.ssm.tags, v.ssm.tags) + parameters = { + tags = merge(local.raw_translated_experimental.ssm.parameters.tags, v.ssm.parameters.tags) + } + housekeeper = { + schedule_expression = coalesce(v.ssm.housekeeper.schedule_expression, local.raw_translated_experimental.ssm.housekeeper.schedule_expression) + state = coalesce(v.ssm.housekeeper.state, local.raw_translated_experimental.ssm.housekeeper.state) + tags = merge(local.raw_translated_experimental.ssm.housekeeper.tags, v.ssm.housekeeper.tags) + lambda = { + memory_size = coalesce(v.ssm.housekeeper.lambda.memory_size, local.raw_translated_experimental.ssm.housekeeper.lambda.memory_size) + timeout = coalesce(v.ssm.housekeeper.lambda.timeout, local.raw_translated_experimental.ssm.housekeeper.lambda.timeout) + } + config = { + tokenPath = try(coalesce( + v.ssm.housekeeper.config.tokenPath, + local.raw_translated_experimental.ssm.housekeeper.config.tokenPath, + ), null) + minimumDaysOld = coalesce(v.ssm.housekeeper.config.minimumDaysOld, local.raw_translated_experimental.ssm.housekeeper.config.minimumDaysOld) + dryRun = coalesce(v.ssm.housekeeper.config.dryRun, local.raw_translated_experimental.ssm.housekeeper.config.dryRun) + } + } + }) + + observability = { + logs = { + level = coalesce(v.observability.logs.level, local.raw_translated_experimental.observability.logs.level) + retention_in_days = coalesce(v.observability.logs.retention_in_days, local.raw_translated_experimental.observability.logs.retention_in_days) + kms_key_id = try(coalesce(v.observability.logs.kms_key_id, local.raw_translated_experimental.observability.logs.kms_key_id), null) + class = coalesce(v.observability.logs.class, local.raw_translated_experimental.observability.logs.class) + tags = merge(local.raw_translated_experimental.observability.logs.tags, v.observability.logs.tags) + } + tracing = { + mode = try(coalesce( + v.observability.tracing.mode, + local.raw_translated_experimental.observability.tracing.mode, + ), null) + capture_http_requests = coalesce(v.observability.tracing.capture_http_requests, local.raw_translated_experimental.observability.tracing.capture_http_requests) + capture_error = coalesce(v.observability.tracing.capture_error, local.raw_translated_experimental.observability.tracing.capture_error) + } + metrics = { + enable = coalesce(v.observability.metrics.enable, local.raw_translated_experimental.observability.metrics.enable) + namespace = coalesce(v.observability.metrics.namespace, local.raw_translated_experimental.observability.metrics.namespace) + metric = { + enable_github_app_rate_limit = coalesce( + v.observability.metrics.metric.enable_github_app_rate_limit, + local.raw_translated_experimental.observability.metrics.metric.enable_github_app_rate_limit, + ) + enable_job_retry = coalesce( + v.observability.metrics.metric.enable_job_retry, + local.raw_translated_experimental.observability.metrics.metric.enable_job_retry, + ) + } + } + } + + compute_provider = { + ec2 = v.compute_provider.ec2 == null ? null : merge(v.compute_provider.ec2, { + vpc_id = try(coalesce(v.compute_provider.ec2.vpc_id, local.raw_translated_experimental.compute_provider.ec2.vpc_id), null) + subnet_ids = v.compute_provider.ec2.subnet_ids != null ? v.compute_provider.ec2.subnet_ids : local.raw_translated_experimental.compute_provider.ec2.subnet_ids + managed_security_group_enabled = coalesce(v.compute_provider.ec2.managed_security_group_enabled, local.raw_translated_experimental.compute_provider.ec2.managed_security_group_enabled) + egress_rules = v.compute_provider.ec2.egress_rules != null ? v.compute_provider.ec2.egress_rules : local.raw_translated_experimental.compute_provider.ec2.egress_rules + additional_security_group_ids = v.compute_provider.ec2.additional_security_group_ids != null ? v.compute_provider.ec2.additional_security_group_ids : local.raw_translated_experimental.compute_provider.ec2.additional_security_group_ids + instance_profile_path = try(coalesce(v.compute_provider.ec2.instance_profile_path, local.raw_translated_experimental.compute_provider.ec2.instance_profile_path), null) + key_name = try(coalesce(v.compute_provider.ec2.key_name, local.raw_translated_experimental.compute_provider.ec2.key_name), null) + associate_public_ipv4_address = coalesce(v.compute_provider.ec2.associate_public_ipv4_address, local.raw_translated_experimental.compute_provider.ec2.associate_public_ipv4_address) + cloudwatch_agent = merge(v.compute_provider.ec2.cloudwatch_agent, { + config = try(coalesce(v.compute_provider.ec2.cloudwatch_agent.config, local.raw_translated_experimental.compute_provider.ec2.cloudwatch_agent.config), null) + }) + binaries_syncer = { + enabled = coalesce(v.compute_provider.ec2.binaries_syncer.enabled, local.raw_translated_experimental.compute_provider.ec2.runner_binaries.enabled) + } + tags = merge(local.raw_translated_experimental.compute_provider.ec2.tags, v.compute_provider.ec2.tags) + }) + } + }) + } + }) +} + +locals { + translated_experimental = merge(local.translated_experimental_base, { + multi_runner_config = { + for k, v in local.translated_experimental_base.multi_runner_config : k => merge(v, { + runner = merge(v.runner, { + labels = sort(setunion( + v.runner.disable_default_labels ? [] : compact([ + "self-hosted", + v.runner.os, + v.runner.architecture, + ]), + flatten(v.matcherConfig.labelMatchers), + compact(v.runner.extra_labels), + )) + }) + + github = merge(v.github, { + enterprise_server = local.translated_experimental_base.enterprise_server + user_agent = local.translated_experimental_base.user_agent + }) + + queue = merge(v.queue, { + event_source_mapping = v.lambda.scale_up.event_source_mapping + }) + + lambda = merge(v.lambda, { + zip = local.translated_experimental_base.lambda.scale.artifact.zip + s3 = { + bucket = local.translated_experimental_base.lambda.scale.artifact.s3 == null ? null : local.translated_experimental_base.lambda.artifact.s3.bucket + key = try(local.translated_experimental_base.lambda.scale.artifact.s3.key, null) + object_version = try(local.translated_experimental_base.lambda.scale.artifact.s3.object_version, null) + } + principals = local.translated_experimental_base.lambda.principals + pool = merge(v.lambda.pool, { + lambda = { + memory_size = v.lambda.pool.memory_size + timeout = v.lambda.pool.timeout + reserved_concurrent_executions = v.lambda.pool.reserved_concurrent_executions + } + }) + }) + + ssm = merge(v.ssm, { + kms_key_id = local.translated_experimental_base.ssm.kms_key_id + }) + + compute_provider = merge(v.compute_provider, { + ec2 = v.compute_provider.ec2 == null ? null : merge(v.compute_provider.ec2, { + binaries_syncer = merge(v.compute_provider.ec2.binaries_syncer, { + s3 = v.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map[ + "${v.runner.os}_${v.runner.architecture}" + ] : null + }) + }) + }) + }) + } + }) +} diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 9226498ad8..8d92647278 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -1,10 +1,6 @@ locals { - tags = merge(var.tags, { - "ghr:environment" = var.prefix - }) - - primary_app_id = coalesce(var.github_app.id_ssm, module.ssm.parameters.github_app_id) - primary_app_key_base64 = coalesce(var.github_app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) + primary_app_id = coalesce(local.translated_experimental.github.app.id_ssm, module.ssm.parameters.github_app_id) + primary_app_key_base64 = coalesce(local.translated_experimental.github.app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) github_app_parameters = { id = concat( @@ -19,20 +15,9 @@ locals { [null], [for p in module.ssm.additional_app_parameters : p.installation_id] ) - webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) + webhook_secret = coalesce(local.translated_experimental.github.app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) } - runner_extra_labels = { for k, v in var.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner_config.runner_extra_labels))) } - - runner_config = { 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[k] }) }), - ) } - # Keep a concrete map type when unrelated configuration values are unknown until apply. tmp_distinct_list_unique_os_and_arch = distinct([ for _, config in lookup(local.runner_config_by_provider, "ec2", {}) : { @@ -42,8 +27,6 @@ locals { 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 } - - ssm_root_path = "/${var.ssm_paths.root}/${var.prefix}" } resource "random_string" "random" { diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 1adb2e0ba4..1491fa1d22 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -52,8 +52,8 @@ output "webhook" { lambda_role = module.webhook.role endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}" webhook = module.webhook.webhook - dispatcher = var.eventbridge.enable ? module.webhook.dispatcher : null - eventbridge = var.eventbridge.enable ? module.webhook.eventbridge : null + dispatcher = local.translated_experimental.webhook.eventbridge.enable ? module.webhook.dispatcher : null + eventbridge = local.translated_experimental.webhook.eventbridge.enable ? module.webhook.eventbridge : null } } @@ -80,7 +80,7 @@ output "ssm_parameters" { } output "instance_termination_watcher" { - value = var.instance_termination_watcher.enable && var.instance_termination_watcher.features.enable_spot_termination_notification_watcher ? { + value = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher ? { lambda = module.instance_termination_watcher[0].spot_termination_notification.lambda lambda_log_group = module.instance_termination_watcher[0].spot_termination_notification.lambda_log_group lambda_role = module.instance_termination_watcher[0].spot_termination_notification.lambda_role @@ -88,7 +88,7 @@ output "instance_termination_watcher" { } output "instance_termination_handler" { - value = var.instance_termination_watcher.enable && var.instance_termination_watcher.features.enable_spot_termination_handler ? { + value = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler ? { lambda = module.instance_termination_watcher[0].spot_termination_handler.lambda lambda_log_group = module.instance_termination_watcher[0].spot_termination_handler.lambda_log_group lambda_role = module.instance_termination_watcher[0].spot_termination_handler.lambda_role diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index 615cd1187d..db05c7b6a6 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -1,13 +1,3 @@ -locals { - sqs_tags = { - for k, v in local.multi_runner_config : k => merge( - var.tags, - v.tags, - v.queue.tags, - ) - } -} - data "aws_iam_policy_document" "deny_insecure_transport" { statement { sid = "DenyInsecureTransport" @@ -36,10 +26,10 @@ data "aws_iam_policy_document" "deny_insecure_transport" { } resource "aws_sqs_queue" "queued_builds" { - for_each = local.multi_runner_config + for_each = local.translated_experimental.multi_runner_config name = "${var.prefix}-${each.key}-queued-builds" delay_seconds = each.value.queue.delay_webhook_event - visibility_timeout_seconds = var.runners_scale_up_lambda_timeout + visibility_timeout_seconds = each.value.queue.visibility_timeout_seconds message_retention_seconds = each.value.queue.job_queue_retention_in_seconds receive_wait_time_seconds = 0 redrive_policy = each.value.queue.redrive_build_queue.enabled ? jsonencode({ @@ -47,31 +37,38 @@ resource "aws_sqs_queue" "queued_builds" { maxReceiveCount = each.value.queue.redrive_build_queue.maxReceiveCount }) : null - 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 + sqs_managed_sse_enabled = local.translated_experimental.queue.encryption.sqs_managed_sse_enabled + kms_master_key_id = local.translated_experimental.queue.encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = local.translated_experimental.queue.encryption.kms_data_key_reuse_period_seconds - tags = local.sqs_tags[each.key] + tags = merge( + local.translated_experimental.tags, + each.value.tags, + each.value.queue.tags, + ) } - resource "aws_sqs_queue_policy" "build_queue_policy" { - for_each = local.multi_runner_config + for_each = local.translated_experimental.multi_runner_config queue_url = aws_sqs_queue.queued_builds[each.key].id policy = data.aws_iam_policy_document.deny_insecure_transport.json } resource "aws_sqs_queue" "queued_builds_dlq" { - for_each = { for config, values in local.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } + for_each = { for config, values in local.translated_experimental.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } name = "${var.prefix}-${each.key}-queued-builds_dead_letter" - 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 = local.sqs_tags[each.key] + sqs_managed_sse_enabled = local.translated_experimental.queue.encryption.sqs_managed_sse_enabled + kms_master_key_id = local.translated_experimental.queue.encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = local.translated_experimental.queue.encryption.kms_data_key_reuse_period_seconds + tags = merge( + local.translated_experimental.tags, + each.value.tags, + each.value.queue.tags, + ) } resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { - for_each = { for config, values in local.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } + for_each = { for config, values in local.translated_experimental.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id policy = data.aws_iam_policy_document.deny_insecure_transport.json } diff --git a/modules/multi-runner/runner-binaries.tf b/modules/multi-runner/runner-binaries.tf index fb511bb3c5..fe6534a7e5 100644 --- a/modules/multi-runner/runner-binaries.tf +++ b/modules/multi-runner/runner-binaries.tf @@ -2,7 +2,7 @@ module "runner_binaries" { source = "../runner-binaries-syncer" for_each = local.unique_os_and_arch prefix = "${var.prefix}-${each.value.os_type}-${each.value.architecture}" - tags = local.tags + tags = merge(local.translated_experimental_base.tags, { "ghr:environment" = var.prefix }) # force mandatory lower case for s3 bucketname distribution_bucket_name = lower("${var.prefix}-${each.value.os_type}-${each.value.architecture}-dist-${random_string.random.result}") @@ -10,36 +10,48 @@ module "runner_binaries" { runner_os = each.value.os_type runner_architecture = each.value.architecture - lambda_s3_bucket = var.lambda_s3_bucket - syncer_lambda_s3_key = var.syncer_lambda_s3_key - syncer_lambda_s3_object_version = var.syncer_lambda_s3_object_version - lambda_runtime = var.lambda_runtime - lambda_architecture = var.lambda_architecture - lambda_zip = var.runner_binaries_syncer_lambda_zip - lambda_memory_size = var.runner_binaries_syncer_memory_size - lambda_timeout = var.runner_binaries_syncer_lambda_timeout - 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 - state_event_rule_binaries_syncer = var.state_event_rule_binaries_syncer - - server_side_encryption_configuration = var.runner_binaries_s3_sse_configuration - s3_tags = var.runner_binaries_s3_tags - s3_versioning = var.runner_binaries_s3_versioning - - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - - log_level = var.log_level - - lambda_subnet_ids = var.lambda_subnet_ids - lambda_security_group_ids = var.lambda_security_group_ids + lambda_s3_bucket = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.s3 == null ? null : local.translated_experimental_base.lambda.artifact.s3.bucket + syncer_lambda_s3_key = try(local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key, null) + syncer_lambda_s3_object_version = try(local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version, null) + lambda_runtime = local.translated_experimental_base.lambda.runtime + lambda_architecture = local.translated_experimental_base.lambda.architecture + lambda_zip = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.zip + lambda_memory_size = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size + lambda_timeout = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.lambda.timeout + lambda_tags = local.translated_experimental_base.lambda.tags + tracing_config = local.translated_experimental_base.observability.tracing + logging_retention_in_days = local.translated_experimental_base.observability.logs.retention_in_days + logging_kms_key_id = local.translated_experimental_base.observability.logs.kms_key_id + log_class = local.translated_experimental_base.observability.logs.class + state_event_rule_binaries_syncer = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.schedule.state + lambda_schedule_expression = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.schedule.expression + + server_side_encryption_configuration = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.enabled ? { + rule = { + bucket_key_enabled = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled + apply_server_side_encryption_by_default = { + sse_algorithm = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm + kms_master_key_id = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id + } + } + } : null + s3_tags = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.tags + s3_versioning = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.versioning + s3_logging_bucket = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.logging.bucket + s3_logging_bucket_prefix = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.logging.prefix + + role_path = try(coalesce(local.translated_experimental_base.lambda.role.path, local.translated_experimental_base.roles.path), null) + role_permissions_boundary = try(coalesce(local.translated_experimental_base.lambda.role.permissions_boundary, local.translated_experimental_base.roles.permissions_boundary), null) + + log_level = local.translated_experimental_base.observability.logs.level + + lambda_subnet_ids = local.translated_experimental_base.lambda.subnet_ids + lambda_security_group_ids = local.translated_experimental_base.lambda.security_group_ids aws_partition = var.aws_partition - lambda_principals = var.lambda_principals + lambda_principals = local.translated_experimental_base.lambda.principals } + locals { runner_binaries_by_os_and_arch_map = { for k, v in module.runner_binaries : k => { arn = v.bucket.arn, id = v.bucket.id, key = v.runner_distribution_object_key } diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index 22fd453f1b..529cf6b68a 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -1,206 +1,31 @@ -locals { - runner_config_v2 = { - for k, v in local.selected_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 - runner = merge(v.runner, { - extra_labels = sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner.extra_labels))) - }) - }) - } - - runner_config_v2_compute_provider = { - for k, v in local.runner_config_v2 : k => merge( - local.compute_provider_types[k] == "ec2" ? { - (local.compute_provider_types[k]) = { - ami = v.compute_provider.ec2.ami - vpc_id = coalesce(v.compute_provider.ec2.vpc_id, var.vpc_id) - subnet_ids = coalesce(v.compute_provider.ec2.subnet_ids, var.subnet_ids) - instance_types = v.compute_provider.ec2.instance_types - instance_target_capacity_type = v.compute_provider.ec2.instance_target_capacity_type - instance_allocation_strategy = v.compute_provider.ec2.instance_allocation_strategy - instance_type_priorities = v.compute_provider.ec2.instance_type_priorities - instance_max_spot_price = v.compute_provider.ec2.instance_max_spot_price - block_device_mappings = v.compute_provider.ec2.block_device_mappings - ebs_optimized = v.compute_provider.ec2.ebs_optimized - instance_profile = v.compute_provider.ec2.instance_profile - instance_profile_path = var.instance_profile_path - enable_on_demand_failover_for_errors = v.compute_provider.ec2.enable_on_demand_failover_for_errors - scale_errors = v.compute_provider.ec2.scale_errors - managed_security_group_enabled = var.enable_managed_runner_security_group - detailed_monitoring_enabled = v.compute_provider.ec2.detailed_monitoring_enabled - ssm_enabled = v.compute_provider.ec2.ssm_enabled - egress_rules = var.runner_egress_rules - additional_security_group_ids = try(coalescelist(v.compute_provider.ec2.additional_security_group_ids, var.runner_additional_security_group_ids), []) - metadata_options = v.compute_provider.ec2.metadata_options - credit_specification = v.compute_provider.ec2.credit_specification - cpu_options = v.compute_provider.ec2.cpu_options - placement = v.compute_provider.ec2.placement - license_specifications = v.compute_provider.ec2.license_specifications - use_dedicated_host = v.compute_provider.ec2.use_dedicated_host - binaries_syncer = { - enabled = v.compute_provider.ec2.binaries_syncer.enabled - s3 = v.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map["${v.runner.os}_${v.runner.architecture}"] : null - } - cloudwatch_agent = { - enabled = v.compute_provider.ec2.cloudwatch_agent.enabled - config = try(coalesce(v.compute_provider.ec2.cloudwatch_agent.config, var.cloudwatch_config), null) - } - log_files = v.compute_provider.ec2.log_files - user_data = v.compute_provider.ec2.user_data - key_name = var.key_name - tags = v.compute_provider.ec2.tags - - create_service_linked_role_spot = v.compute_provider.ec2.create_service_linked_role_spot - associate_public_ipv4_address = var.associate_public_ipv4_address - } - } : {}, - local.compute_provider_types[k] != "ec2" ? { - (local.compute_provider_types[k]) = v.compute_provider[local.compute_provider_types[k]] - } : {}, - ) - } -} - module "runner_stacks" { source = "../runner-stack" - for_each = local.runner_config_v2 + for_each = local.use_multi_runner_config_v2 ? local.translated_experimental.multi_runner_config : {} 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 - additional_trust_policy_json = each.value.runner.iam.additional_trust_policy_json - 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 = { + tags = merge( + each.value.tags, + { "ghr:environment" = var.prefix }, + ) + runner = each.value.runner + github = merge(each.value.github, { + app_parameters = local.github_app_parameters + }) + queue = merge(each.value.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 = local.runner_config_v2_compute_provider[each.key] + arn = aws_sqs_queue.queued_builds[each.key].arn + url = aws_sqs_queue.queued_builds[each.key].url + } + }) + lambda = each.value.lambda + scale_up = each.value.lambda.scale_up + scale_down = each.value.lambda.scale_down + pool = each.value.lambda.pool + job_retry = each.value.job_retry + ssm = each.value.ssm + observability = each.value.observability + compute_provider = each.value.compute_provider } diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 892113dcc7..197e2d9090 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,130 +1,165 @@ module "runners" { - source = "../runners" - for_each = local.runner_config + source = "../runners" + for_each = local.use_multi_runner_config_v2 ? {} : local.translated_experimental.multi_runner_config + aws_region = var.aws_region aws_partition = var.aws_partition - vpc_id = coalesce(each.value.runner_config.vpc_id, var.vpc_id) - subnet_ids = coalesce(each.value.runner_config.subnet_ids, var.subnet_ids) + vpc_id = each.value.compute_provider.ec2.vpc_id + subnet_ids = each.value.compute_provider.ec2.subnet_ids prefix = "${var.prefix}-${each.key}" - tags = merge(local.tags, { + tags = merge(each.value.tags, { "ghr:environment" = "${var.prefix}-${each.key}" }) - 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 + s3_runner_binaries = each.value.compute_provider.ec2.binaries_syncer.s3 - ssm_paths = { - root = "${local.ssm_root_path}/${each.key}" - tokens = "${var.ssm_paths.runners}/tokens" - config = "${var.ssm_paths.runners}/config" - } + ssm_paths = each.value.ssm.paths - 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_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_config.runner_architecture - ami = each.value.runner_config.ami + runner_architecture = each.value.runner.architecture + ami = each.value.compute_provider.ec2.ami == null ? null : { + filter = each.value.compute_provider.ec2.ami.filter + owners = each.value.compute_provider.ec2.ami.owners + id_ssm_parameter_arn = try(each.value.compute_provider.ec2.ami.id_ssm_parameter.arn, null) + kms_key_arn = try(each.value.compute_provider.ec2.ami.kms_key.arn, null) + } - sqs_build_queue = { "arn" : each.value.arn, "url" : each.value.url } + sqs_build_queue = { + arn = aws_sqs_queue.queued_builds[each.key].arn + url = aws_sqs_queue.queued_builds[each.key].url + } github_app_parameters = local.github_app_parameters - 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.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.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 - 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.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 - 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.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.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.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.runner_config.runner_ec2_tags - - create_service_linked_role_spot = each.value.runner_config.create_service_linked_role_spot - - 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 - user_agent = var.user_agent - - kms_key_arn = var.kms_key_arn - - log_level = var.log_level - - pool_config = each.value.runner_config.pool_config - pool_lambda_timeout = var.pool_lambda_timeout - 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 = each.value.runner_config.job_retry - - metrics = var.metrics + 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.lambda.scale_up.job_queued_check_enabled + disable_runner_autoupdate = each.value.runner.auto_update_disabled + enable_managed_runner_security_group = each.value.compute_provider.ec2.managed_security_group_enabled + enable_runner_detailed_monitoring = each.value.compute_provider.ec2.detailed_monitoring_enabled + scale_down_schedule_expression = each.value.lambda.scale_down.schedule_expression + minimum_running_time_in_minutes = each.value.lambda.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.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.lambda.scale_down.idle_config + enable_ssm_on_runners = each.value.compute_provider.ec2.ssm_enabled + egress_rules = each.value.compute_provider.ec2.egress_rules + runner_additional_security_group_ids = each.value.compute_provider.ec2.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 = local.translated_experimental.lambda.scale.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + runners_lambda_s3_key = try(local.translated_experimental.lambda.scale.artifact.s3.key, null) + runners_lambda_s3_object_version = try(local.translated_experimental.lambda.scale.artifact.s3.object_version, null) + lambda_runtime = each.value.lambda.runtime + lambda_architecture = each.value.lambda.architecture + lambda_zip = local.translated_experimental.lambda.scale.artifact.zip + lambda_scale_up_memory_size = each.value.lambda.scale_up.memory_size + lambda_event_source_mapping_batch_size = each.value.queue.event_source_mapping.batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = each.value.queue.event_source_mapping.maximum_batching_window_in_seconds + lambda_timeout_scale_up = each.value.lambda.scale_up.timeout + lambda_scale_down_memory_size = each.value.lambda.scale_down.memory_size + lambda_timeout_scale_down = each.value.lambda.scale_down.timeout + lambda_subnet_ids = each.value.lambda.subnet_ids + lambda_security_group_ids = each.value.lambda.security_group_ids + lambda_tags = each.value.lambda.tags + tracing_config = each.value.observability.tracing + logging_retention_in_days = each.value.observability.logs.retention_in_days + logging_kms_key_id = each.value.observability.logs.kms_key_id + log_class = each.value.observability.logs.class + enable_cloudwatch_agent = each.value.compute_provider.ec2.cloudwatch_agent.enabled + cloudwatch_config = each.value.compute_provider.ec2.cloudwatch_agent.config + 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 = each.value.ssm.parameters.tags + + scale_up_reserved_concurrent_executions = each.value.lambda.scale_up.reserved_concurrent_executions + + instance_profile_path = each.value.compute_provider.ec2.instance_profile_path + role_path = each.value.runner.iam.path + role_permissions_boundary = each.value.runner.iam.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 + enable_user_data_debug_logging = each.value.compute_provider.ec2.user_data.debug_logging_enabled + runner_hook_job_started = each.value.runner.hooks.job_started + runner_hook_job_completed = each.value.runner.hooks.job_completed + key_name = each.value.compute_provider.ec2.key_name + runner_ec2_tags = each.value.compute_provider.ec2.tags + + create_service_linked_role_spot = each.value.compute_provider.ec2.create_service_linked_role_spot + + runner_iam_role_managed_policy_arns = values(each.value.runner.iam.managed_policy_arns) + iam_overrides = { + override_instance_profile = each.value.compute_provider.ec2.instance_profile != null + instance_profile_name = try(each.value.compute_provider.ec2.instance_profile.name, null) + override_runner_role = each.value.runner.iam.role != null + runner_role_arn = try(each.value.runner.iam.role.arn, null) + } + + ghes_url = local.translated_experimental.enterprise_server.url + ghes_ssl_verify = local.translated_experimental.enterprise_server.ssl_verify + user_agent = local.translated_experimental.user_agent + + kms_key_arn = local.translated_experimental.ssm.kms_key_id + + log_level = each.value.observability.logs.level + + pool_config = each.value.lambda.pool.config + pool_lambda_timeout = each.value.lambda.pool.lambda.timeout + pool_lambda_memory_size = each.value.lambda.pool.lambda.memory_size + pool_runner_owner = each.value.lambda.pool.runner_owner + pool_include_busy_runners = each.value.lambda.pool.include_busy_runners + pool_lambda_reserved_concurrent_executions = each.value.lambda.pool.lambda.reserved_concurrent_executions + associate_public_ipv4_address = each.value.compute_provider.ec2.associate_public_ipv4_address + + ssm_housekeeper = { + schedule_expression = each.value.ssm.housekeeper.schedule_expression + state = each.value.ssm.housekeeper.state + lambda_memory_size = each.value.ssm.housekeeper.lambda.memory_size + lambda_timeout = each.value.ssm.housekeeper.lambda.timeout + config = each.value.ssm.housekeeper.config + } + + job_retry = { + enable = each.value.job_retry.enabled + delay_in_seconds = each.value.job_retry.delay_in_seconds + delay_backoff = each.value.job_retry.delay_backoff + lambda_memory_size = each.value.job_retry.lambda.memory_size + lambda_reserved_concurrent_executions = each.value.job_retry.lambda.reserved_concurrent_executions + lambda_timeout = each.value.job_retry.lambda.timeout + max_attempts = each.value.job_retry.max_attempts + } + + metrics = { + enable = each.value.observability.metrics.enable + namespace = each.value.observability.metrics.namespace + metric = { + enable_github_app_rate_limit = each.value.observability.metrics.metric.enable_github_app_rate_limit + enable_job_retry = each.value.observability.metrics.metric.enable_job_retry + enable_spot_termination_warning = local.translated_experimental.observability.metrics.metric.enable_spot_termination_warning + } + } } diff --git a/modules/multi-runner/ssm.tf b/modules/multi-runner/ssm.tf index 3e4b740fdd..2f1c199dff 100644 --- a/modules/multi-runner/ssm.tf +++ b/modules/multi-runner/ssm.tf @@ -1,8 +1,13 @@ module "ssm" { - source = "../ssm" - kms_key_arn = var.kms_key_arn - path_prefix = "${local.ssm_root_path}/${var.ssm_paths.app}" - github_app = var.github_app - additional_github_apps = var.additional_github_apps - tags = local.tags + source = "../ssm" + + kms_key_arn = local.translated_experimental.ssm.kms_key_id + path_prefix = "${trimsuffix(coalesce(local.translated_experimental.ssm.paths.root, "/github-action-runners/${var.prefix}"), "/")}/${local.translated_experimental.ssm.paths.app}" + github_app = local.translated_experimental.github.app + additional_github_apps = local.translated_experimental.github.additional_apps + tags = merge( + local.translated_experimental.tags, + local.translated_experimental.ssm.tags, + { "ghr:environment" = var.prefix }, + ) } diff --git a/modules/multi-runner/termination-watcher.tf b/modules/multi-runner/termination-watcher.tf index 750db361bf..85c2f4320f 100644 --- a/modules/multi-runner/termination-watcher.tf +++ b/modules/multi-runner/termination-watcher.tf @@ -1,36 +1,39 @@ -locals { - lambda_instance_termination_watcher = { +module "instance_termination_watcher" { + source = "../termination-watcher" + count = try(local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled, false) ? 1 : 0 + + config = { prefix = var.prefix - tags = local.tags + tags = merge(local.translated_experimental.tags, { "ghr:environment" = var.prefix }) aws_partition = var.aws_partition - architecture = var.lambda_architecture - principals = var.lambda_principals - runtime = var.lambda_runtime - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids - 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 - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - s3_bucket = var.lambda_s3_bucket - tracing_config = var.tracing_config - lambda_tags = var.lambda_tags - metrics = var.metrics - enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration - github_app_parameters = var.instance_termination_watcher.enable_runner_deregistration ? { + architecture = local.translated_experimental.lambda.architecture + principals = local.translated_experimental.lambda.principals + runtime = local.translated_experimental.lambda.runtime + security_group_ids = local.translated_experimental.lambda.security_group_ids + subnet_ids = local.translated_experimental.lambda.subnet_ids + log_level = local.translated_experimental.observability.logs.level + log_class = local.translated_experimental.observability.logs.class + logging_kms_key_id = local.translated_experimental.observability.logs.kms_key_id + ssm_kms_key_id = local.translated_experimental.ssm.kms_key_id + logging_retention_in_days = local.translated_experimental.observability.logs.retention_in_days + role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) + role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) + s3_bucket = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + tracing_config = local.translated_experimental.observability.tracing + lambda_tags = local.translated_experimental.lambda.tags + metrics = local.translated_experimental.observability.metrics + features = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.features + memory_size = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.memory_size + timeout = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.timeout + zip = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip + s3_key = try(local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key, null) + s3_object_version = try(local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version, null) + enable_runner_deregistration = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration + github_app_parameters = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration ? { id = local.github_app_parameters.id[0] key_base64 = local.github_app_parameters.key_base64[0] } : null - ghes_url = var.ghes_url - environment_variables = var.instance_termination_watcher.environment_variables + ghes_url = local.translated_experimental.enterprise_server.url + environment_variables = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables } } - -module "instance_termination_watcher" { - source = "../termination-watcher" - count = var.instance_termination_watcher.enable ? 1 : 0 - - config = merge(local.lambda_instance_termination_watcher, var.instance_termination_watcher) -} diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 5695a15af3..25f3f220b2 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,26 +2,26 @@ ## 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 | -|------|--------|---------| +| ---- | ------ | ------- | | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs @@ -31,7 +31,7 @@ No inputs. ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | | [runner\_stack\_keys](#output\_runner\_stack\_keys) | n/a | \ No newline at end of file diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf index d4174101bb..3591dcc20d 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -1,6 +1,5 @@ -# Keep the runner lane key and provider selection caller-known while passing an -# apply-time value through the lane, matching callers that attach a policy -# created in the same plan. +# Keep the runner lane key, provider selection, and optional KMS scalar +# caller-known while passing apply-time ARNs through the configuration. resource "random_id" "managed_policy" { byte_length = 4 } @@ -8,10 +7,11 @@ resource "random_id" "managed_policy" { module "multi_runner" { source = "../../.." - aws_region = "eu-west-1" - prefix = "computed-inputs" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] + aws_region = "eu-west-1" + prefix = "computed-inputs" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/generated-${random_id.managed_policy.hex}" github_app = { id = "123456" @@ -25,7 +25,48 @@ module "multi_runner" { syncer_lambda_s3_key = "runner-binaries-syncer.zip" experimental = { - multi_runner_config_v2 = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + + lambda = { + artifact = { + s3 = { + bucket = "nested-lambda-artifacts" + } + } + scale = { + artifact = { + s3 = { + key = "nested-runners.zip" + } + } + } + webhook = { + artifact = { + s3 = { + key = "webhook.zip" + } + } + } + } + + compute_provider = { + ec2 = { + vpc_id = "vpc-nested-12345678" + subnet_ids = ["subnet-nested-12345678"] + } + } + + ssm = { + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/generated-${random_id.managed_policy.hex}" + } + + multi_runner_config = { linux = { runner = { os = "linux" diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index fda12b8f7e..508ac45f80 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -28,6 +28,7 @@ variables { lambda_s3_bucket = "lambda-artifacts" webhook_lambda_s3_key = "webhook.zip" + runners_lambda_zip = "README.md" runners_lambda_s3_key = "runners.zip" syncer_lambda_s3_key = "runner-binaries-syncer.zip" } @@ -39,6 +40,15 @@ run "empty_runner_configurations_return_empty_output_maps" { 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." } + + assert { + condition = ( + length(local.raw_translated_experimental.multi_runner_config) == 0 + && length(local.translated_experimental.multi_runner_config) == 0 + && length(module.runner_stacks) == 0 + ) + error_message = "An empty stable and experimental configuration must translate to an empty raw lane map without selecting a v2 runner stack." + } } run "stable_v1_keeps_legacy_runner_module" { @@ -50,15 +60,226 @@ run "stable_v1_keeps_legacy_runner_module" { Precedence = "global" } + repository_white_list = ["legacy-owner/legacy-repository"] + queue_selection_strategy = "random" + eventbridge = { + enable = false + accept_events = ["workflow_job"] + } + matcher_config_parameter_store_tier = "Advanced" + webhook_lambda_apigateway_access_log_settings = { + destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:legacy-api-access" + format = "$context.requestId" + } + webhook_lambda_s3_object_version = "legacy-webhook-version" + + lambda_runtime = "nodejs20.x" + lambda_architecture = "x86_64" + lambda_subnet_ids = ["subnet-legacy-lambda"] + lambda_security_group_ids = ["sg-legacy-lambda"] + lambda_principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:role/legacy-lambda-principal"] + }] + webhook_lambda_memory_size = 320 + webhook_lambda_timeout = 25 + runners_scale_up_lambda_timeout = 47 + runners_lambda_s3_object_version = "legacy-runners-version" + runner_binaries_syncer_memory_size = 640 + runner_binaries_syncer_lambda_timeout = 70 + role_path = "/legacy/" + role_permissions_boundary = "arn:aws:iam::123456789012:policy/legacy-boundary" + ghes_url = "https://legacy.example.com" + ghes_ssl_verify = false + user_agent = "legacy-user-agent" + log_level = "warn" + logging_retention_in_days = 14 + logging_kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + log_class = "STANDARD" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" + + queue_encryption = { + kms_data_key_reuse_period_seconds = 300 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/legacy-queue" + sqs_managed_sse_enabled = null + } + + lambda_tags = { + LegacyLambda = "legacy" + } + + tracing_config = { + mode = "Active" + capture_http_requests = true + capture_error = false + } + + metrics = { + enable = true + namespace = "LegacyMetrics" + metric = { + enable_github_app_rate_limit = true + enable_job_retry = false + enable_spot_termination_warning = false + } + } + + ssm_paths = { + root = "legacy-root" + app = "legacy-app" + runners = "legacy-runners" + webhook = "legacy-webhook" + } + + parameter_store_tags = { + LegacyParameter = "legacy" + Precedence = "legacy-parameter" + } + + runners_ssm_housekeeper = { + schedule_expression = "rate(12 hours)" + enabled = false + lambda_memory_size = 320 + lambda_timeout = 45 + config = { + tokenPath = "/legacy/cleanup/tokens" + minimumDaysOld = 5 + dryRun = true + } + } + + instance_termination_watcher = { + enable = true + enable_runner_deregistration = true + environment_variables = { + LEGACY_WATCHER = "true" + } + features = { + enable_spot_termination_handler = true + enable_spot_termination_notification_watcher = true + } + memory_size = 448 + timeout = 35 + s3_key = "termination-watcher.zip" + s3_object_version = "legacy-watcher-version" + } + + enable_ami_housekeeper = true + ami_housekeeper_lambda_memory_size = 384 + ami_housekeeper_lambda_timeout = 90 + ami_housekeeper_lambda_s3_key = "ami-housekeeper.zip" + ami_housekeeper_lambda_s3_object_version = "legacy-ami-housekeeper-version" + ami_housekeeper_lambda_schedule_expression = "rate(2 days)" + ami_housekeeper_cleanup_config = { + maxItems = 7 + minimumDaysOld = 14 + dryRun = true + } + + experimental = { + tags = { + ExperimentalOnly = "ignored" + } + roles = { + path = "/experimental/" + } + lambda = { + artifact = { + s3 = { + bucket = "experimental-ignored-artifacts" + } + } + runtime = "nodejs22.x" + architecture = "sparc64" + principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:role/experimental-ignored-principal"] + }] + subnet_ids = ["subnet-experimental-lambda"] + security_group_ids = ["sg-experimental-lambda"] + tags = { + ExperimentalLambda = "ignored" + } + webhook = { + memory_size = 896 + timeout = 90 + } + } + github = { + app = { + id = "incomplete-experimental-id" + } + additional_apps = [{ id = "incomplete-additional-app-id" }] + } + enterprise_server = { + url = "https://experimental.example.com" + ssl_verify = true + } + user_agent = "experimental-user-agent" + ssm = { + paths = { + root = "relative-experimental-root" + tokens = "experimental-tokens" + config = "experimental-config" + } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-ssm" + tags = { + ExperimentalSsm = "ignored" + } + parameters = { + tags = { + ExperimentalParameter = "ignored" + } + } + housekeeper = { + schedule_expression = "rate(1 hour)" + state = "PAUSED" + lambda = { + memory_size = 896 + timeout = 90 + } + config = { + tokenPath = "/experimental/cleanup/tokens" + minimumDaysOld = 1 + dryRun = false + } + } + } + observability = { + logs = { + level = "verbose" + retention_in_days = 30 + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-logs" + class = "ARCHIVE" + } + tracing = { + mode = "PassThrough" + capture_http_requests = false + capture_error = true + } + metrics = { + enable = false + namespace = "ExperimentalMetrics" + metric = { + enable_github_app_rate_limit = false + enable_job_retry = true + enable_spot_termination_warning = true + } + } + } + } + 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 + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 2 + enable_runner_binaries_syncer = true + enable_organization_runners = true + delay_webhook_event = 17 + job_queue_retention_in_seconds = 12345 } matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64"]] @@ -71,23 +292,224 @@ run "stable_v1_keeps_legacy_runner_module" { } } + assert { + condition = ( + toset(keys(local.raw_translated_experimental)) == toset([ + "tags", + "roles", + "runner", + "github", + "enterprise_server", + "user_agent", + "webhook", + "lambda", + "queue", + "ssm", + "observability", + "compute_provider", + "multi_runner_config", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"])) == toset([ + "tags", + "runner", + "github", + "lambda", + "queue", + "job_retry", + "ssm", + "observability", + "compute_provider", + "matcherConfig", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].lambda)) == toset([ + "runtime", + "architecture", + "subnet_ids", + "security_group_ids", + "tags", + "role", + "scale_up", + "scale_down", + "pool", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].queue)) == toset([ + "delay_webhook_event", + "job_queue_retention_in_seconds", + "visibility_timeout_seconds", + "redrive_build_queue", + "tags", + ]) + && toset(keys(local.raw_translated_experimental.queue)) == toset([ + "delay_webhook_event", + "job_queue_retention_in_seconds", + "visibility_timeout_seconds", + "redrive_build_queue", + "tags", + "encryption", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["ec2"]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].ssm), "kms_key_id") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"]), "scale_up") + ) + error_message = "Stable v1 must translate into the exact raw experimental schema before defaults, queue event-source mappings, binary artifacts, or runner-stack component shapes are resolved." + } + + assert { + condition = ( + toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3)) == toset(["arn", "id", "key"]) + ) + error_message = "Stable translation must discover binary-syncer lanes from the base object, then enrich the final canonical lane with its resolved S3 distribution." + } + + assert { + condition = ( + local.raw_translated_experimental.tags == var.tags + && local.raw_translated_experimental.roles.path == var.role_path + && local.raw_translated_experimental.roles.permissions_boundary == var.role_permissions_boundary + && local.raw_translated_experimental.github.app == var.github_app + && local.raw_translated_experimental.github.additional_apps == var.additional_github_apps + && local.raw_translated_experimental.github.repository_white_list == var.repository_white_list + && local.raw_translated_experimental.enterprise_server.url == var.ghes_url + && local.raw_translated_experimental.enterprise_server.ssl_verify == var.ghes_ssl_verify + && local.raw_translated_experimental.user_agent == var.user_agent + && local.raw_translated_experimental.webhook.queue_selection_strategy == var.queue_selection_strategy + && local.raw_translated_experimental.webhook.eventbridge == var.eventbridge + && local.raw_translated_experimental.webhook.matcher_config_parameter_store_tier == var.matcher_config_parameter_store_tier + && local.raw_translated_experimental.lambda.scale.artifact.zip == null + && local.raw_translated_experimental.lambda.artifact.s3.bucket == var.lambda_s3_bucket + && local.raw_translated_experimental.lambda.scale.artifact.s3.key == var.runners_lambda_s3_key + && local.raw_translated_experimental.lambda.scale.artifact.s3.object_version == var.runners_lambda_s3_object_version + && local.raw_translated_experimental.lambda.runtime == var.lambda_runtime + && local.raw_translated_experimental.lambda.architecture == var.lambda_architecture + && local.raw_translated_experimental.lambda.principals == var.lambda_principals + && local.raw_translated_experimental.lambda.subnet_ids == var.lambda_subnet_ids + && local.raw_translated_experimental.lambda.security_group_ids == var.lambda_security_group_ids + && local.raw_translated_experimental.lambda.tags == var.lambda_tags + && local.raw_translated_experimental.lambda.scale_up.event_source_mapping.batch_size == var.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == var.lambda_event_source_mapping_maximum_batching_window_in_seconds + && local.raw_translated_experimental.lambda.webhook.artifact.zip == null + && local.raw_translated_experimental.lambda.webhook.artifact.s3.key == var.webhook_lambda_s3_key + && local.raw_translated_experimental.lambda.webhook.artifact.s3.object_version == var.webhook_lambda_s3_object_version + && local.raw_translated_experimental.lambda.webhook.api_gateway_access_log_settings == var.webhook_lambda_apigateway_access_log_settings + && local.raw_translated_experimental.lambda.webhook.memory_size == var.webhook_lambda_memory_size + && local.raw_translated_experimental.lambda.webhook.timeout == var.webhook_lambda_timeout + && local.raw_translated_experimental.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && local.raw_translated_experimental.queue.encryption == var.queue_encryption + && local.raw_translated_experimental.ssm.paths.root == "/legacy-root/github-actions" + && local.raw_translated_experimental.ssm.paths.app == var.ssm_paths.app + && local.raw_translated_experimental.ssm.paths.webhook == var.ssm_paths.webhook + && local.raw_translated_experimental.ssm.paths.tokens == "${var.ssm_paths.runners}/tokens" + && local.raw_translated_experimental.ssm.paths.config == "${var.ssm_paths.runners}/config" + && local.raw_translated_experimental.ssm.kms_key_id == var.kms_key_arn + && local.raw_translated_experimental.ssm.parameters.tags == var.parameter_store_tags + && local.raw_translated_experimental.observability.logs.level == var.log_level + && local.raw_translated_experimental.observability.logs.retention_in_days == var.logging_retention_in_days + && local.raw_translated_experimental.observability.logs.kms_key_id == var.logging_kms_key_id + && local.raw_translated_experimental.observability.logs.class == var.log_class + && local.raw_translated_experimental.observability.tracing == var.tracing_config + && local.raw_translated_experimental.observability.metrics.enable == var.metrics.enable + && local.raw_translated_experimental.observability.metrics.namespace == var.metrics.namespace + && local.raw_translated_experimental.observability.metrics.metric.enable_github_app_rate_limit == var.metrics.metric.enable_github_app_rate_limit + && local.raw_translated_experimental.observability.metrics.metric.enable_job_retry == var.metrics.metric.enable_job_retry + && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination + && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination_warning == var.metrics.metric.enable_spot_termination_warning + && local.raw_translated_experimental.compute_provider.ec2.vpc_id == var.vpc_id + && local.raw_translated_experimental.compute_provider.ec2.subnet_ids == var.subnet_ids + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.enabled == var.enable_ami_housekeeper + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.cleanup_config == var.ami_housekeeper_cleanup_config + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.zip == null + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key == var.ami_housekeeper_lambda_s3_key + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.object_version == var.ami_housekeeper_lambda_s3_object_version + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.memory_size == var.ami_housekeeper_lambda_memory_size + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.timeout == var.ami_housekeeper_lambda_timeout + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.schedule.expression == var.ami_housekeeper_lambda_schedule_expression + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled == var.instance_termination_watcher.enable + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.features == var.instance_termination_watcher.features + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration == var.instance_termination_watcher.enable_runner_deregistration + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables == var.instance_termination_watcher.environment_variables + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip == null + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key == var.instance_termination_watcher.s3_key + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version == var.instance_termination_watcher.s3_object_version + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.memory_size == var.instance_termination_watcher.memory_size + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.timeout == var.instance_termination_watcher.timeout + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.enabled + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm == var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.tags == var.runner_binaries_s3_tags + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == var.runner_binaries_s3_versioning + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key == var.syncer_lambda_s3_key + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version == var.syncer_lambda_s3_object_version + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size == var.runner_binaries_syncer_memory_size + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.timeout == var.runner_binaries_syncer_lambda_timeout + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == var.state_event_rule_binaries_syncer + && local.raw_translated_experimental.multi_runner_config["linux"].runner.os == var.multi_runner_config["linux"].runner_config.runner_os + && local.raw_translated_experimental.multi_runner_config["linux"].runner.architecture == var.multi_runner_config["linux"].runner_config.runner_architecture + && local.raw_translated_experimental.multi_runner_config["linux"].runner.maximum_count == var.multi_runner_config["linux"].runner_config.runners_maximum_count + && local.raw_translated_experimental.multi_runner_config["linux"].github.organization_runners == var.multi_runner_config["linux"].runner_config.enable_organization_runners + && local.raw_translated_experimental.multi_runner_config["linux"].lambda.scale_up.event_source_mapping.batch_size == var.multi_runner_config["linux"].runner_config.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.multi_runner_config["linux"].queue.delay_webhook_event == var.multi_runner_config["linux"].runner_config.delay_webhook_event + && local.raw_translated_experimental.multi_runner_config["linux"].queue.job_queue_retention_in_seconds == var.multi_runner_config["linux"].runner_config.job_queue_retention_in_seconds + && local.raw_translated_experimental.multi_runner_config["linux"].queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && local.raw_translated_experimental.multi_runner_config["linux"].queue.redrive_build_queue == var.multi_runner_config["linux"].redrive_build_queue + && local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled + && local.raw_translated_experimental.multi_runner_config["linux"].matcherConfig == var.multi_runner_config["linux"].matcherConfig + ) + error_message = "Stable v1 flat and per-runner inputs must populate every raw translation family while conflicting experimental globals remain inactive." + } + + assert { + condition = ( + !var.runners_ssm_housekeeper.enabled + && local.translated_experimental.ssm.housekeeper.state == "DISABLED" + && keys(module.runners) == ["linux"] + ) + error_message = "Stable v1 must translate runners_ssm_housekeeper.enabled=false to the DISABLED child event-rule state while retaining module.runners ownership." + } + assert { condition = keys(local.runner_config_by_provider.ec2) == ["linux"] error_message = "Stable multi_runner_config entries must route to the EC2 provider." } assert { - condition = keys(local.runner_config) == ["linux"] && length(local.runner_config_v2) == 0 + condition = ( + !local.use_multi_runner_config_v2 + && toset(keys(local.raw_translated_experimental.multi_runner_config)) == toset(["linux"]) + && toset(keys(local.translated_experimental.multi_runner_config)) == toset(["linux"]) + && length(module.runner_stacks) == 0 + && keys(module.runners) == ["linux"] + ) error_message = "Stable multi_runner_config entries must keep the original runner configuration and remain isolated from v2." } assert { condition = ( - contains(keys(local.runner_config["linux"]), "runner_config") - && !contains(keys(local.runner_config["linux"]), "compute_provider") - && local.runner_config["linux"].runner_config.enable_organization_runners + var.experimental.github.app.key_base64 == null + && var.experimental.github.app.webhook_secret == null + && var.experimental.github.additional_apps[0].key_base64 == null + && var.experimental.lambda.architecture == "sparc64" + && var.experimental.observability.logs.level == "verbose" + && var.experimental.observability.logs.class == "ARCHIVE" + && var.experimental.ssm.paths.root == "relative-experimental-root" + && var.experimental.ssm.housekeeper.state == "PAUSED" + && !local.use_multi_runner_config_v2 + && keys(module.runners) == ["linux"] ) - error_message = "Stable module inputs must retain the original local.runner_config shape instead of using the v1-to-v2 translation." + error_message = "Invalid but unused experimental sibling globals must remain gated when a stable v1 configuration owns the deployment." + } + + assert { + condition = ( + contains(keys(local.translated_experimental.multi_runner_config["linux"]), "compute_provider") + && !contains(keys(local.translated_experimental.multi_runner_config["linux"]), "runner_config") + && local.translated_experimental.multi_runner_config["linux"].github.organization_runners + ) + error_message = "Stable module inputs must use the canonical translated lane while retaining stable module.runners ownership." } assert { @@ -108,6 +530,161 @@ run "stable_v1_keeps_legacy_runner_module" { error_message = "Stable multi_runner_config queues must continue to receive exactly the module-level tags." } + assert { + condition = ( + aws_sqs_queue.queued_builds["linux"].delay_seconds == 17 + && aws_sqs_queue.queued_builds["linux"].message_retention_seconds == 12345 + && aws_sqs_queue.queued_builds["linux"].visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && aws_sqs_queue.queued_builds["linux"].kms_master_key_id == var.queue_encryption.kms_master_key_id + && aws_sqs_queue.queued_builds["linux"].kms_data_key_reuse_period_seconds == var.queue_encryption.kms_data_key_reuse_period_seconds + && aws_sqs_queue.queued_builds_dlq["linux"].kms_master_key_id == var.queue_encryption.kms_master_key_id + && aws_sqs_queue.queued_builds_dlq["linux"].kms_data_key_reuse_period_seconds == var.queue_encryption.kms_data_key_reuse_period_seconds + ) + error_message = "Stable v1 queues must retain per-runner delay and retention plus flat timeout and encryption inputs after translation." + } + + assert { + condition = ( + output.runners_map["linux"].lambda_up.runtime == "nodejs20.x" + && output.runners_map["linux"].lambda_up.s3_bucket == var.lambda_s3_bucket + && output.runners_map["linux"].lambda_up.s3_key == var.runners_lambda_s3_key + && output.runners_map["linux"].lambda_up.s3_object_version == "legacy-runners-version" + && output.runners_map["linux"].role_scale_up.path == "/legacy/" + && output.webhook.lambda.runtime == "nodejs20.x" + && output.webhook.lambda.architectures == tolist(["x86_64"]) + && output.webhook.lambda.memory_size == 320 + && output.webhook.lambda.timeout == 25 + && output.webhook.lambda.s3_bucket == "lambda-artifacts" + && output.webhook.lambda.s3_key == "webhook.zip" + && output.webhook.lambda.s3_object_version == "legacy-webhook-version" + && toset(output.webhook.lambda.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) + && toset(output.webhook.lambda.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) + && output.webhook.lambda.tags["LegacyLambda"] == "legacy" + && !contains(keys(output.webhook.lambda.tags), "ExperimentalLambda") + && output.webhook.lambda_role.path == "/legacy/" + && output.webhook.lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + && toset(jsondecode(output.webhook.lambda.environment[0].variables["REPOSITORY_ALLOW_LIST"])) == toset(var.repository_white_list) + && output.webhook.lambda.environment[0].variables["QUEUE_SELECTION_STRATEGY"] == var.queue_selection_strategy + && output.webhook.eventbridge == null + && output.webhook.dispatcher == null + && output.runners_map["linux"].lambda_up.environment[0].variables["GHES_URL"] == "https://legacy.example.com" + && output.runners_map["linux"].lambda_up.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && output.runners_map["linux"].lambda_up.environment[0].variables["USER_AGENT"] == "legacy-user-agent" + ) + error_message = "The stable v1 runner and shared webhook must use flat Lambda, artifact, network, tag, role, and GitHub inputs while ignoring experimental globals." + } + + assert { + condition = ( + keys(output.binaries_syncer_map) == ["linux_x64"] + && output.binaries_syncer_map["linux_x64"].lambda.runtime == "nodejs20.x" + && output.binaries_syncer_map["linux_x64"].lambda.architectures == tolist(["x86_64"]) + && output.binaries_syncer_map["linux_x64"].lambda.memory_size == 640 + && output.binaries_syncer_map["linux_x64"].lambda.timeout == 70 + && toset(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) + && toset(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) + && output.binaries_syncer_map["linux_x64"].lambda.tags["LegacyLambda"] == "legacy" + && !contains(keys(output.binaries_syncer_map["linux_x64"].lambda.tags), "ExperimentalLambda") + && output.binaries_syncer_map["linux_x64"].lambda_role.path == "/legacy/" + && output.binaries_syncer_map["linux_x64"].lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + && output.binaries_syncer_map["linux_x64"].lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && output.binaries_syncer_map["linux_x64"].lambda.tracing_config[0].mode == "Active" + && output.binaries_syncer_map["linux_x64"].lambda_log_group.retention_in_days == 14 + && output.binaries_syncer_map["linux_x64"].lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.binaries_syncer_map["linux_x64"].lambda_log_group.log_group_class == "STANDARD" + ) + error_message = "The stable v1 binary syncer must receive the same flat Lambda, network, role, and observability values through the translated configuration." + } + + assert { + condition = ( + output.instance_termination_watcher.lambda.function.runtime == "nodejs20.x" + && output.instance_termination_watcher.lambda.function.architectures == tolist(["x86_64"]) + && output.instance_termination_watcher.lambda.function.memory_size == 448 + && output.instance_termination_watcher.lambda.function.timeout == 35 + && output.instance_termination_watcher.lambda.function.s3_bucket == "lambda-artifacts" + && output.instance_termination_watcher.lambda.function.s3_key == "termination-watcher.zip" + && output.instance_termination_watcher.lambda.function.s3_object_version == "legacy-watcher-version" + && toset(output.instance_termination_watcher.lambda.function.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) + && toset(output.instance_termination_watcher.lambda.function.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) + && output.instance_termination_watcher.lambda.function.tags["LegacyLambda"] == "legacy" + && !contains(keys(output.instance_termination_watcher.lambda.function.tags), "ExperimentalLambda") + && output.instance_termination_watcher.lambda_role.path == "/legacy/" + && output.instance_termination_watcher.lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://legacy.example.com" + && output.instance_termination_watcher.lambda.function.environment[0].variables["LEGACY_WATCHER"] == "true" + && output.instance_termination_watcher.lambda.function.environment[0].variables["LOG_LEVEL"] == "warn" + && output.instance_termination_watcher.lambda.function.tracing_config[0].mode == "Active" + && output.instance_termination_watcher.lambda_log_group.retention_in_days == 14 + && output.instance_termination_watcher.lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.instance_termination_watcher.lambda_log_group.log_group_class == "STANDARD" + ) + error_message = "The stable v1 termination watcher must receive flat component settings and translated global Lambda, role, GitHub, and observability values." + } + + assert { + condition = ( + length(module.ami_housekeeper) == 1 + && module.ami_housekeeper[0].lambda.runtime == "nodejs20.x" + && module.ami_housekeeper[0].lambda.architectures == tolist(["x86_64"]) + && module.ami_housekeeper[0].lambda.memory_size == 384 + && module.ami_housekeeper[0].lambda.timeout == 90 + && module.ami_housekeeper[0].lambda.s3_bucket == "lambda-artifacts" + && module.ami_housekeeper[0].lambda.s3_key == "ami-housekeeper.zip" + && module.ami_housekeeper[0].lambda.s3_object_version == "legacy-ami-housekeeper-version" + && module.ami_housekeeper[0].lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).maxItems == 7 + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).minimumDaysOld == 14 + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).dryRun + && module.ami_housekeeper[0].lambda_role.path == "/legacy/" + && module.ami_housekeeper[0].lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + ) + error_message = "The stable v1 AMI housekeeper must preserve flat component settings through the translated compute-provider global while inheriting translated Lambda, role, and observability values." + } + + assert { + condition = ( + output.runners_map["linux"].lambda_up.environment[0].variables["LOG_LEVEL"] == "WARN" + && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LegacyMetrics" + && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && output.runners_map["linux"].lambda_up_log_group.retention_in_days == 14 + && output.runners_map["linux"].lambda_up_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.runners_map["linux"].lambda_up_log_group.log_group_class == "STANDARD" + && output.runners_map["linux"].lambda_up.tracing_config[0].mode == "Active" + && output.webhook.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && output.webhook.lambda.tracing_config[0].mode == "Active" + && output.webhook.lambda_log_group.retention_in_days == 14 + && output.webhook.lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.webhook.lambda_log_group.log_group_class == "STANDARD" + ) + error_message = "Experimental observability globals must not override stable v1 logging, tracing, or metrics inputs." + } + + assert { + condition = ( + local.translated_experimental.ssm.paths.root == "/legacy-root/github-actions" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" + && var.kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" + && output.ssm_parameters.id.name == "/legacy-root/github-actions/legacy-app/github_app_id" + && output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == "/legacy-root/github-actions/legacy-webhook/runner-matcher-config" + && output.runners_map["linux"].lambda_up.environment[0].variables["SSM_TOKEN_PATH"] == "/legacy-root/github-actions/linux/legacy-runners/tokens" + && output.runners_map["linux"].lambda_up.environment[0].variables["SSM_CONFIG_PATH"] == "/legacy-root/github-actions/linux/legacy-runners/config" + && tomap({ + for tag in jsondecode(output.runners_map["linux"].lambda_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + tag.Key => tag.Value + }) == tomap({ + StableGlobal = "global" + LegacyParameter = "legacy" + "ghr:environment" = "github-actions-linux" + Precedence = "legacy-parameter" + }) + ) + error_message = "Experimental SSM globals must not affect stable v1 shared paths, runner paths, KMS selection, or parameter tags." + } + assert { condition = keys(output.runners_map) == ["linux"] error_message = "Stable multi_runner_config must preserve the public runner map key." @@ -162,8 +739,206 @@ run "experimental_v2_routes_through_provider_stack" { } }] + tags = { + FlatModule = "ignored" + } + + repository_white_list = ["flat-owner/flat-repository"] + queue_selection_strategy = "all" + eventbridge = { + enable = false + accept_events = ["workflow_job"] + } + matcher_config_parameter_store_tier = "Advanced" + webhook_lambda_apigateway_access_log_settings = { + destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:flat-api-access" + format = "$context.requestId" + } + webhook_lambda_s3_object_version = "flat-webhook-version" + + role_path = "/flat-role/" + role_permissions_boundary = "arn:aws:iam::123456789012:policy/flat-boundary" + ghes_url = "https://flat.example.com" + ghes_ssl_verify = false + user_agent = "flat-user-agent" + + lambda_runtime = "nodejs20.x" + lambda_architecture = "x86_64" + runners_lambda_zip = "flat-runners-ignored.zip" + lambda_subnet_ids = ["subnet-flat-lambda"] + lambda_security_group_ids = ["sg-flat-lambda"] + lambda_principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:role/flat-lambda-principal"] + }] + scale_up_lambda_memory_size = 600 + runners_scale_up_lambda_timeout = 45 + scale_down_lambda_memory_size = 700 + runners_scale_down_lambda_timeout = 75 + webhook_lambda_memory_size = 384 + webhook_lambda_timeout = 20 + runner_binaries_syncer_memory_size = 704 + runner_binaries_syncer_lambda_timeout = 80 + pool_lambda_timeout = 90 + pool_lambda_reserved_concurrent_executions = 3 + lambda_event_source_mapping_batch_size = 7 + lambda_event_source_mapping_maximum_batching_window_in_seconds = 2 + log_level = "error" + logging_retention_in_days = 60 + logging_kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/flat-logs" + log_class = "INFREQUENT_ACCESS" + kms_key_arn = null + + queue_encryption = { + kms_data_key_reuse_period_seconds = 600 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/flat-queue" + sqs_managed_sse_enabled = null + } + + lambda_tags = { + FlatLambda = "ignored" + } + + enable_managed_runner_security_group = false + runner_additional_security_group_ids = ["sg-flat-runner"] + cloudwatch_config = "flat-cloudwatch-config" + instance_profile_path = "/flat-instance-profile/" + key_name = "flat-key" + associate_public_ipv4_address = true + + runner_egress_rules = [{ + cidr_blocks = ["10.0.0.0/8"] + ipv6_cidr_blocks = [] + prefix_list_ids = [] + from_port = 443 + protocol = "tcp" + security_groups = [] + self = false + to_port = 443 + description = "flat-only" + }] + + tracing_config = { + mode = "Active" + capture_http_requests = true + capture_error = false + } + + metrics = { + enable = true + namespace = "FlatMetrics" + metric = { + enable_github_app_rate_limit = false + enable_job_retry = true + enable_spot_termination_warning = false + } + } + + ssm_paths = { + root = "flat-root" + app = "flat-app" + runners = "flat-runners" + webhook = "flat-webhook" + } + + parameter_store_tags = { + FlatParameter = "flat" + } + + runners_ssm_housekeeper = { + schedule_expression = "rate(8 hours)" + enabled = false + lambda_memory_size = 640 + lambda_timeout = 55 + config = { + tokenPath = "/flat/cleanup/tokens" + minimumDaysOld = 4 + dryRun = true + } + } + + instance_termination_watcher = { + enable = true + memory_size = 900 + timeout = 90 + s3_key = "flat-termination-watcher.zip" + environment_variables = { + FLAT_WATCHER = "ignored" + } + } + + enable_ami_housekeeper = true + ami_housekeeper_lambda_memory_size = 900 + ami_housekeeper_lambda_timeout = 90 + ami_housekeeper_lambda_s3_key = "flat-ami-housekeeper.zip" + ami_housekeeper_lambda_schedule_expression = "rate(1 hour)" + + multi_runner_config = { + legacy = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["t3.large"] + runners_maximum_count = 1 + enable_runner_binaries_syncer = false + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "legacy"]] + } + } + } + experimental = { - multi_runner_config_v2 = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + additional_apps = [{ + id_ssm = { + name = "/github-runner/additional-app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-id" + } + key_base64_ssm = { + name = "/github-runner/additional-key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-key-base64" + } + installation_id_ssm = { + name = "/github-runner/additional-installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-installation-id" + } + }] + } + + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + + compute_provider = { + ec2 = { + vpc_id = "vpc-experimental-defaults" + subnet_ids = ["subnet-experimental-defaults"] + runner_binaries = { + syncer = { + artifact = { + zip = "README.md" + } + } + } + } + } + + multi_runner_config = { linux = { runner = { os = "linux" @@ -181,24 +956,26 @@ run "experimental_v2_routes_through_provider_stack" { github = { organization_runners = true } - scale_down = { - idle_config = [{ - cron = "* * * * *" - timeZone = "UTC" - idleCount = 1 - }] - } - pool = { - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] + lambda = { + scale_down = { + idle_config = [{ + cron = "* * * * *" + timeZone = "UTC" + idleCount = 1 + }] + } + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } } compute_provider = { ec2 = { instance_types = ["m5.large"] binaries_syncer = { - enabled = false + enabled = true } } } @@ -223,9 +1000,17 @@ run "experimental_v2_routes_through_provider_stack" { } } + assert { + condition = ( + local.use_multi_runner_config_v2 + && jsonencode(local.raw_translated_experimental) == jsonencode(var.experimental) + ) + error_message = "A non-empty experimental v2 map must pass through the exact experimental object without translation-time default resolution or stable-input fallback." + } + assert { condition = keys(local.runner_config_by_provider.ec2) == ["linux"] - error_message = "Experimental multi_runner_config_v2 entries must route to the EC2 provider." + error_message = "Experimental multi_runner_config entries must route to the EC2 provider." } assert { @@ -237,13 +1022,23 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = length(local.runner_config) == 0 && keys(local.runner_config_v2) == ["linux"] - error_message = "Experimental multi_runner_config_v2 entries must remain isolated in the v2 configuration map." + condition = toset(keys(module.runner_stacks)) == toset(["linux"]) && toset(keys(local.translated_experimental.multi_runner_config)) == toset(["linux"]) + error_message = "Experimental multi_runner_config entries must remain isolated in the v2 configuration map." } assert { - condition = toset(local.runner_config_v2["linux"].runner.extra_labels) == toset(["self-hosted", "linux", "x64"]) - error_message = "Experimental runner labels must include labels declared by its matcher configuration." + condition = ( + toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null + && keys(output.binaries_syncer_map) == ["linux_x64"] + ) + error_message = "V2 binary discovery must use the pure base lane, enrich the final canonical lane with S3, and create the corresponding shared syncer resources." + } + + assert { + condition = toset(flatten(local.translated_experimental.multi_runner_config["linux"].matcherConfig.labelMatchers)) == toset(["self-hosted", "linux", "x64"]) + error_message = "The canonical experimental lane must retain labels declared by its matcher configuration for the runner adapter." } assert { @@ -258,84 +1053,373 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["linux"] - error_message = "Experimental multi_runner_config_v2 entries must dispatch through module.runner_stacks." + error_message = "A non-empty experimental.multi_runner_config map must take priority over stable multi_runner_config and dispatch only through module.runner_stacks." } assert { condition = ( - length(local.github_app_parameters.id) == 2 - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == join(":", [for p in local.github_app_parameters.id : p.name]) - && module.runner_stacks["linux"].scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) - && module.runner_stacks["linux"].pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) + length(local.translated_experimental.tags) == 0 + && local.translated_experimental.roles.path == null + && local.translated_experimental.roles.permissions_boundary == null + && local.translated_experimental.multi_runner_config["linux"].runner.boot_time_in_minutes == 5 + && !local.translated_experimental.multi_runner_config["linux"].runner.disable_default_labels + && local.translated_experimental.multi_runner_config["linux"].runner.group_name == "Default" + && local.translated_experimental.multi_runner_config["linux"].runner.name_prefix == "" + && !local.translated_experimental.multi_runner_config["linux"].runner.run_as_root + && local.translated_experimental.multi_runner_config["linux"].runner.run_as == "ec2-user" + && !local.translated_experimental.multi_runner_config["linux"].runner.ephemeral + && local.translated_experimental.multi_runner_config["linux"].runner.jit_config_enabled == null + && !local.translated_experimental.multi_runner_config["linux"].runner.auto_update_disabled + && local.translated_experimental.multi_runner_config["linux"].runner.hooks.job_completed == "" + && local.translated_experimental.multi_runner_config["linux"].runner.iam.path == null + && local.translated_experimental.multi_runner_config["linux"].runner.iam.permissions_boundary == null ) - error_message = "Experimental v2 control-plane Lambdas must preserve the complete multi-app parameter lists from the shared multi-runner configuration." + error_message = "Experimental v2 runner, tag, and role defaults must be self-contained and must not inherit deliberately different stable inputs." } assert { - condition = keys(aws_sqs_queue.queued_builds) == ["linux"] - error_message = "Common queue ownership must preserve the experimental runner configuration key." + condition = ( + local.translated_experimental.lambda.scale.artifact.zip == "README.md" + && local.translated_experimental.lambda.scale.artifact.s3 == null + && local.translated_experimental.lambda.artifact.s3.bucket == null + && local.translated_experimental.multi_runner_config["linux"].lambda.zip == "README.md" + && local.translated_experimental.multi_runner_config["linux"].lambda.s3.bucket == null + && local.translated_experimental.multi_runner_config["linux"].lambda.s3.key == null + && local.translated_experimental.multi_runner_config["linux"].lambda.s3.object_version == null + && length(local.translated_experimental.lambda.principals) == 0 + && local.translated_experimental.multi_runner_config["linux"].lambda.runtime == "nodejs24.x" + && local.translated_experimental.multi_runner_config["linux"].lambda.architecture == "arm64" + && length(local.translated_experimental.multi_runner_config["linux"].lambda.subnet_ids) == 0 + && length(local.translated_experimental.multi_runner_config["linux"].lambda.security_group_ids) == 0 + && length(local.translated_experimental.multi_runner_config["linux"].lambda.tags) == 0 + && local.translated_experimental.multi_runner_config["linux"].lambda.role.path == null + && local.translated_experimental.multi_runner_config["linux"].lambda.role.permissions_boundary == null + && module.runner_stacks["linux"].scale_up.lambda.runtime == "nodejs24.x" + && module.runner_stacks["linux"].scale_up.lambda.filename == "README.md" + && module.runner_stacks["linux"].scale_up.lambda.s3_bucket == null + && module.runner_stacks["linux"].scale_up.lambda.memory_size == 512 + && module.runner_stacks["linux"].scale_up.lambda.timeout == 30 + && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.reserved_concurrent_executions == 1 + && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.job_queued_check_enabled == null + && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.event_source_mapping.batch_size == 10 + && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && module.runner_stacks["linux"].scale_down.lambda.memory_size == 512 + && module.runner_stacks["linux"].scale_down.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].lambda.scale_down.schedule_expression == "cron(*/5 * * * ? *)" + && local.translated_experimental.multi_runner_config["linux"].lambda.scale_down.minimum_running_time_in_minutes == null + && module.runner_stacks["linux"].pool.lambda.memory_size == 512 + && module.runner_stacks["linux"].pool.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].lambda.pool.reserved_concurrent_executions == 1 + && !local.translated_experimental.multi_runner_config["linux"].lambda.pool.include_busy_runners + && local.translated_experimental.multi_runner_config["linux"].lambda.pool.runner_owner == null + ) + error_message = "Experimental v2 runner-stack Lambda components must inherit the concrete nested defaults and ignore every corresponding stable Lambda input." } assert { - condition = length(output.runners_map) == 0 - error_message = "Experimental multi_runner_config_v2 must not add nested entries to the stable runners_map output." + condition = ( + local.translated_experimental.multi_runner_config["linux"].queue.delay_webhook_event == 30 + && local.translated_experimental.multi_runner_config["linux"].queue.job_queue_retention_in_seconds == 86400 + && local.translated_experimental.multi_runner_config["linux"].queue.visibility_timeout_seconds == 180 + && !local.translated_experimental.multi_runner_config["linux"].queue.redrive_build_queue.enabled + && length(local.translated_experimental.multi_runner_config["linux"].queue.tags) == 0 + && local.translated_experimental.queue.encryption == var.experimental.queue.encryption + && local.translated_experimental.queue.encryption.sqs_managed_sse_enabled + && local.translated_experimental.queue.encryption.kms_master_key_id == null + && local.translated_experimental.queue.encryption.kms_data_key_reuse_period_seconds == null + && aws_sqs_queue.queued_builds["linux"].delay_seconds == 30 + && aws_sqs_queue.queued_builds["linux"].message_retention_seconds == 86400 + && aws_sqs_queue.queued_builds["linux"].visibility_timeout_seconds == 180 + && aws_sqs_queue.queued_builds["linux"].sqs_managed_sse_enabled + && aws_sqs_queue.queued_builds["linux"].kms_master_key_id == null + ) + error_message = "Experimental v2 queues must use concrete nested defaults, including a six-times-Lambda visibility timeout and SQS-managed encryption, instead of flat queue inputs." } assert { - condition = keys(output.runners_map_v2) == ["linux"] - error_message = "Experimental multi_runner_config_v2 must expose its runner configuration key through runners_map_v2." + condition = ( + local.translated_experimental.github.app == var.experimental.github.app + && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps + && length(local.translated_experimental.github.repository_white_list) == 0 + && local.translated_experimental.enterprise_server == var.experimental.enterprise_server + && local.translated_experimental.user_agent == var.experimental.user_agent + && local.translated_experimental.enterprise_server.url == null + && local.translated_experimental.enterprise_server.ssl_verify + && local.translated_experimental.user_agent == "github-aws-runners" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["GHES_URL"] == null + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "1" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_stacks["linux"].scale_down.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_stacks["linux"].pool.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + ) + error_message = "V2 runner stacks must use concrete nested GitHub connection defaults rather than deliberately different flat GHES and user-agent inputs." } assert { - condition = toset(keys(output.runners_map_v2["linux"])) == toset( - [ - "provider", - "runner", - "scale_up", - "scale_down", - "pool", - ] + condition = ( + local.translated_experimental.webhook.queue_selection_strategy == "first" + && local.translated_experimental.webhook.eventbridge.enable + && length(local.translated_experimental.webhook.eventbridge.accept_events) == 0 + && local.translated_experimental.webhook.matcher_config_parameter_store_tier == "Standard" + && local.translated_experimental.lambda.webhook.artifact.zip == "README.md" + && local.translated_experimental.lambda.webhook.artifact.s3 == null + && local.translated_experimental.lambda.webhook.api_gateway_access_log_settings == null + && local.translated_experimental.lambda.scale_up.event_source_mapping.batch_size == 10 + && local.translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 ) - error_message = "Experimental v2 runners_map_v2 entries must group common and provider resources by owner." + error_message = "V2 webhook controls, the explicitly nested local artifact, API access-log defaults, and scale-up event-source mappings must avoid flat-input fallback." } assert { condition = ( - 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"]) + local.translated_experimental.multi_runner_config["linux"].observability.logs.level == "info" + && local.translated_experimental.multi_runner_config["linux"].observability.logs.retention_in_days == 180 + && local.translated_experimental.multi_runner_config["linux"].observability.logs.kms_key_id == null + && local.translated_experimental.multi_runner_config["linux"].observability.logs.class == "STANDARD" + && length(local.translated_experimental.multi_runner_config["linux"].observability.logs.tags) == 0 + && local.translated_experimental.multi_runner_config["linux"].observability.tracing.mode == null + && !local.translated_experimental.multi_runner_config["linux"].observability.tracing.capture_http_requests + && !local.translated_experimental.multi_runner_config["linux"].observability.tracing.capture_error + && !local.translated_experimental.multi_runner_config["linux"].observability.metrics.enable + && local.translated_experimental.multi_runner_config["linux"].observability.metrics.namespace == "GitHub Runners" + && local.translated_experimental.multi_runner_config["linux"].observability.metrics.metric.enable_github_app_rate_limit + && local.translated_experimental.multi_runner_config["linux"].observability.metrics.metric.enable_job_retry ) - error_message = "Experimental v2 common resources must use the nested runner, scale-up, scale-down, and pool contracts." + error_message = "Experimental v2 observability must use its concrete nested logging, tracing, and metrics defaults instead of stable inputs." } assert { condition = ( - toset(keys(output.runners_map_v2["linux"].provider)) == toset(["ec2"]) - && toset(keys(output.runners_map_v2["linux"].provider.ec2)) == toset([ - "launch_template", - "runners_log_groups", - "logfiles", - ]) + local.translated_experimental.multi_runner_config["linux"].ssm.paths.root == "/github-action-runners/github-actions/linux" + && local.translated_experimental.multi_runner_config["linux"].ssm.paths.tokens == "runners/tokens" + && local.translated_experimental.multi_runner_config["linux"].ssm.paths.config == "runners/config" + && var.kms_key_arn == null + && local.translated_experimental.ssm.kms_key_id == null + && length(local.translated_experimental.multi_runner_config["linux"].ssm.tags) == 0 + && length(local.translated_experimental.multi_runner_config["linux"].ssm.parameters.tags) == 0 + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.schedule_expression == "rate(1 day)" + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.state == "ENABLED" + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.memory_size == 512 + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.config.tokenPath == null + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.config.minimumDaysOld == 1 + && !local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.config.dryRun ) - error_message = "Experimental v2 must expose only EC2-owned resources under runners_map_v2..provider.ec2." + error_message = "Experimental v2 SSM must use self-contained path, tag, and housekeeper defaults without deriving ownership or values from stable SSM inputs." } assert { condition = ( - !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") + module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "INFO" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GitHub Runners" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/github-action-runners/github-actions/linux/runners/tokens" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/github-action-runners/github-actions/linux/runners/config" + && module.runner_stacks["linux"].scale_up.log_group.retention_in_days == 180 + && module.runner_stacks["linux"].scale_up.log_group.kms_key_id == null + && module.runner_stacks["linux"].scale_up.log_group.log_group_class == "STANDARD" + && length(module.runner_stacks["linux"].scale_up.lambda.tracing_config) == 0 + && jsondecode(module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) == [{ + Key = "ghr:environment" + Value = "github-actions" + }] ) - error_message = "Experimental v2 must expose only its nested schema through runners_map_v2 without legacy flat fields." + error_message = "Concrete experimental observability and SSM defaults must reach runner-stack resources without stable-input leakage." } assert { - 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." - } + condition = ( + output.webhook.lambda.runtime == "nodejs24.x" + && output.webhook.lambda.architectures == tolist(["arm64"]) + && output.webhook.lambda.memory_size == 256 + && output.webhook.lambda.timeout == 10 + && output.webhook.lambda.s3_bucket == null + && output.webhook.lambda.s3_key == null + && output.webhook.lambda.s3_object_version == null + && length(output.webhook.lambda.vpc_config) == 1 + && length(output.webhook.lambda.vpc_config[0].subnet_ids) == 0 + && length(output.webhook.lambda.vpc_config[0].security_group_ids) == 0 + && !contains(keys(output.webhook.lambda.tags), "FlatModule") + && !contains(keys(output.webhook.lambda.tags), "FlatLambda") + && output.webhook.lambda_role.path == "/github-actions/" + && output.webhook.lambda_role.permissions_boundary == null + && output.webhook.eventbridge != null + && output.webhook.dispatcher != null + && jsondecode(output.webhook.dispatcher.lambda.environment[0].variables["REPOSITORY_ALLOW_LIST"]) == [] + && output.webhook.dispatcher.lambda.environment[0].variables["QUEUE_SELECTION_STRATEGY"] == "first" + && output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == "/github-action-runners/github-actions/webhook/runner-matcher-config" + && output.webhook.lambda.environment[0].variables["LOG_LEVEL"] == "INFO" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && length(output.webhook.lambda.tracing_config) == 0 + && output.webhook.lambda_log_group.retention_in_days == 180 + && output.webhook.lambda_log_group.kms_key_id == null + && output.webhook.lambda_log_group.log_group_class == "STANDARD" + && var.kms_key_arn == null + ) + error_message = "The shared webhook must use translated v2 Lambda, nested artifact, network, tag, role, SSM/KMS, and observability values without flat-input leakage." + } + + assert { + condition = ( + local.translated_experimental.ssm.paths.app == "app" + && output.ssm_parameters.id.name == "/github-action-runners/github-actions/app/github_app_id" + ) + error_message = "Shared SSM parameters must use the translated v2 root and app defaults instead of flat ssm_paths." + } + + assert { + condition = ( + local.translated_experimental.compute_provider.ec2.runner_binaries.enabled + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm == "AES256" + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id == null + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == "Disabled" + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.logging.bucket == null + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.logging.prefix == null + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.zip == "README.md" + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 == null + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size == 256 + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.timeout == 300 + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.expression == "cron(27 * * * ? *)" + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == "ENABLED" + ) + error_message = "The nested v2 runner-binary block must own concrete defaults independently of matching flat syncer and bucket inputs." + } + + assert { + condition = ( + keys(output.binaries_syncer_map) == ["linux_x64"] + && output.binaries_syncer_map["linux_x64"].lambda.runtime == "nodejs24.x" + && output.binaries_syncer_map["linux_x64"].lambda.architectures == tolist(["arm64"]) + && output.binaries_syncer_map["linux_x64"].lambda.memory_size == 256 + && output.binaries_syncer_map["linux_x64"].lambda.timeout == 300 + && output.binaries_syncer_map["linux_x64"].lambda.filename == "README.md" + && output.binaries_syncer_map["linux_x64"].lambda.s3_bucket == null + && output.binaries_syncer_map["linux_x64"].lambda.s3_key == null + && output.binaries_syncer_map["linux_x64"].lambda.s3_object_version == null + && length(output.binaries_syncer_map["linux_x64"].lambda.vpc_config) == 1 + && length(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].subnet_ids) == 0 + && length(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].security_group_ids) == 0 + && !contains(keys(output.binaries_syncer_map["linux_x64"].lambda.tags), "FlatModule") + && !contains(keys(output.binaries_syncer_map["linux_x64"].lambda.tags), "FlatLambda") + && output.binaries_syncer_map["linux_x64"].lambda_role.path == "/github-actions-linux-x64/" + && output.binaries_syncer_map["linux_x64"].lambda_role.permissions_boundary == null + && output.binaries_syncer_map["linux_x64"].lambda.environment[0].variables["LOG_LEVEL"] == "INFO" + && length(output.binaries_syncer_map["linux_x64"].lambda.tracing_config) == 0 + && output.binaries_syncer_map["linux_x64"].lambda_log_group.retention_in_days == 180 + && output.binaries_syncer_map["linux_x64"].lambda_log_group.kms_key_id == null + && output.binaries_syncer_map["linux_x64"].lambda_log_group.log_group_class == "STANDARD" + ) + error_message = "The v2 binary syncer must inherit translated global Lambda, role, tags, observability, artifact, and component defaults without flat-input leakage." + } + + assert { + condition = ( + local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.vpc_id == "vpc-experimental-defaults" + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.subnet_ids == tolist(["subnet-experimental-defaults"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.managed_security_group_enabled + && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules) == 1 + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules[0].cidr_blocks == tolist(["0.0.0.0/0"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules[0].ipv6_cidr_blocks == tolist(["::/0"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules[0].protocol == "-1" + && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.additional_security_group_ids) == 0 + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.cloudwatch_agent.config == null + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.instance_profile_path == null + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.key_name == null + && !local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.associate_public_ipv4_address + && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.tags) == 0 + && !local.translated_experimental.compute_provider.ec2.ami.housekeeper.enabled + && !local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled + && length(module.ami_housekeeper) == 0 + && length(module.instance_termination_watcher) == 0 + && output.instance_termination_watcher == null + ) + error_message = "V2 EC2 lanes must use nested network inputs and concrete nested EC2 defaults instead of corresponding stable inputs." + } + + assert { + condition = ( + local.translated_experimental.github.app == var.experimental.github.app + && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps + && local.translated_experimental.github.app == var.github_app + && local.translated_experimental.github.additional_apps == var.additional_github_apps + && length(local.github_app_parameters.id) == 2 + && length(module.ssm.additional_app_parameters) == 1 + && module.ssm.additional_app_parameters[0].id.name == "/github-runner/additional-app-id" + && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == join(":", [for p in local.github_app_parameters.id : p.name]) + && module.runner_stacks["linux"].scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) + && module.runner_stacks["linux"].pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) + ) + error_message = "Experimental v2 control-plane Lambdas must preserve the complete multi-app parameter lists from the shared multi-runner configuration." + } + + assert { + condition = keys(aws_sqs_queue.queued_builds) == ["linux"] && keys(local.runner_matcher_config) == ["linux"] + error_message = "Common queues and webhook matcher configuration must contain only the selected experimental runner configuration key." + } + + assert { + condition = length(output.runners_map) == 0 + error_message = "Experimental multi_runner_config must not add nested entries to the stable runners_map output." + } + + assert { + condition = keys(output.runners_map_v2) == ["linux"] + error_message = "Experimental multi_runner_config must expose its runner configuration key through runners_map_v2." + } + + assert { + condition = toset(keys(output.runners_map_v2["linux"])) == toset( + [ + "provider", + "runner", + "scale_up", + "scale_down", + "pool", + ] + ) + error_message = "Experimental v2 runners_map_v2 entries must group common and provider resources by owner." + } + + assert { + condition = ( + 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 = ( + toset(keys(output.runners_map_v2["linux"].provider)) == toset(["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_v2..provider.ec2." + } + + assert { + condition = ( + !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 only its nested schema through runners_map_v2 without legacy flat fields." + } + + assert { + condition = local.runner_config_by_provider.ec2["linux"].lambda.scale_down.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"].runner.iam.managed_policy_arns.readonly == "arn:aws:iam::aws:policy/ReadOnlyAccess" @@ -351,90 +1435,282 @@ run "experimental_v2_routes_through_provider_stack" { } } -run "experimental_v2_layers_shared_and_component_tags" { +run "experimental_v2_applies_global_defaults_and_lane_overrides" { command = plan variables { - tags = { - GlobalOnly = "global" - Precedence = "global" + lambda_runtime = "nodejs20.x" + lambda_s3_bucket = "flat-lambda-artifacts" + role_path = "/flat/" + runner_egress_rules = null + ghes_url = "https://flat-termination.example.com" + ghes_ssl_verify = true + user_agent = "flat-shared-user-agent" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" + runners_lambda_s3_key = "flat-runners-ignored.zip" + runners_lambda_s3_object_version = "flat-runners-ignored-version" + repository_white_list = ["flat-owner/flat-repository"] + queue_selection_strategy = "random" + eventbridge = { + enable = true + accept_events = ["push"] + } + matcher_config_parameter_store_tier = "Standard" + webhook_lambda_apigateway_access_log_settings = { + destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:flat-api-access" + format = "$context.requestId" } + webhook_lambda_s3_object_version = "flat-webhook-version" - lambda_tags = { - SharedLambdaOnly = "shared-lambda" - Precedence = "shared-lambda" + lambda_principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:role/flat-lambda-principal"] + }] + + instance_termination_watcher = { + enable = false + memory_size = 999 + timeout = 99 + s3_key = "flat-termination-watcher.zip" } + enable_ami_housekeeper = false + ami_housekeeper_lambda_memory_size = 999 + ami_housekeeper_lambda_timeout = 99 + ami_housekeeper_lambda_s3_key = "flat-ami-housekeeper.zip" + ami_housekeeper_lambda_schedule_expression = "rate(1 hour)" + experimental = { - multi_runner_config_v2 = { - tagged = { - tags = { - RunnerConfigOnly = "runner-config" - Precedence = "runner-config" - } + roles = { + path = "/experimental/" + } - runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - tags = { - RunnerOnly = "runner" - Precedence = "runner" - } - } + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + group_name = "global-group" + } - lambda = { - tags = { - ConfigLambdaOnly = "config-lambda" - Precedence = "config-lambda" - } - } + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + repository_white_list = ["nested-owner/nested-repository"] + } + enterprise_server = { + url = "https://experimental-shared.example.com" + ssl_verify = false + } + user_agent = "experimental-runner-user-agent" - queue = { - redrive_build_queue = { - enabled = true - maxReceiveCount = 3 + webhook = { + queue_selection_strategy = "all" + eventbridge = { + enable = false + accept_events = ["workflow_job"] + } + matcher_config_parameter_store_tier = "Advanced" + } + + lambda = { + artifact = { + s3 = { + bucket = "experimental-lambda-artifacts" + } + } + scale = { + artifact = { + s3 = { + key = "nested-runners.zip" + object_version = "nested-runners-version" } - tags = { - SharedQueueOnly = "shared-queue" - Precedence = "shared-queue" + } + } + runtime = "nodejs22.x" + principals = [{ + type = "Service" + identifiers = ["states.amazonaws.com"] + }] + scale_up = { + memory_size = 768 + timeout = 40 + event_source_mapping = { + batch_size = 25 + } + } + scale_down = { + timeout = 75 + } + webhook = { + artifact = { + s3 = { + key = "nested-webhook.zip" + object_version = "nested-webhook-version" } } + api_gateway_access_log_settings = { + destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" + format = "$context.requestId $context.status" + } + memory_size = 384 + } + pool = { + memory_size = 384 + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + } - scale_up = { - tags = { - ScaleUpOnly = "scale-up" - Precedence = "scale-up" + queue = { + delay_webhook_event = 23 + job_queue_retention_in_seconds = 172800 + visibility_timeout_seconds = 240 + redrive_build_queue = { + enabled = true + maxReceiveCount = 7 + } + tags = { + GlobalQueue = "global" + Precedence = "global" + } + encryption = { + kms_data_key_reuse_period_seconds = 900 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" + sqs_managed_sse_enabled = null + } + } + + ssm = { + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" + } + + compute_provider = { + ec2 = { + vpc_id = "vpc-experimental" + subnet_ids = ["subnet-experimental"] + ami = { + housekeeper = { + enabled = true + cleanup_config = { + maxItems = 5 + minimumDaysOld = 10 + dryRun = true + } + artifact = { + s3 = { + key = "nested-ami-housekeeper.zip" + object_version = "nested-ami-housekeeper-version" + } + } + lambda = { + memory_size = 448 + timeout = 120 + } + schedule = { + expression = "rate(3 days)" + } } } - - scale_down = { - tags = { - ScaleDownOnly = "scale-down" - Precedence = "scale-down" + instance_termination_watcher = { + enabled = true + features = { + enable_spot_termination_handler = false + enable_spot_termination_notification_watcher = true + } + enable_runner_deregistration = true + environment_variables = { + NESTED_WATCHER = "true" + } + artifact = { + s3 = { + key = "nested-termination-watcher.zip" + object_version = "nested-watcher-version" + } + } + lambda = { + memory_size = 432 + timeout = 41 } } - - observability = { - logs = { + runner_binaries = { + enabled = false + s3 = { tags = { - SharedLogOnly = "shared-log" - Precedence = "shared-log" + BinaryBucket = "global" + } + versioning = "Enabled" + logging = { + bucket = "runner-binaries-access-logs" + prefix = "runner-binaries/" + } + } + syncer = { + artifact = { + s3 = { + key = "nested-runner-binaries-syncer.zip" + object_version = "nested-version" + } + } + lambda = { + memory_size = 384 + timeout = 240 + } + schedule = { + expression = "rate(2 hours)" + state = "DISABLED" } } } + } + } + multi_runner_config = { + resolved = { + runner = { + group_name = "lane-group" + maximum_count = 4 + } + lambda = { + role = { + path = "/lane-lambda/" + } + scale_up = { + memory_size = 896 + event_source_mapping = { + batch_size = 50 + } + } + pool = { + memory_size = 448 + } + } + job_retry = { + enabled = true + } + queue = { + delay_webhook_event = 11 + visibility_timeout_seconds = 300 + tags = { + LaneQueue = "lane" + Precedence = "lane" + } + } compute_provider = { ec2 = { instance_types = ["m5.large"] + subnet_ids = ["subnet-lane"] binaries_syncer = { - enabled = false + enabled = true } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "tagged"]] + labelMatchers = [["self-hosted", "linux", "x64", "resolved"]] } } } @@ -442,103 +1718,2344 @@ run "experimental_v2_layers_shared_and_component_tags" { } 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." + condition = ( + local.translated_experimental.multi_runner_config["resolved"].runner.os == "linux" + && local.translated_experimental.multi_runner_config["resolved"].runner.architecture == "x64" + && local.translated_experimental.multi_runner_config["resolved"].runner.maximum_count == 4 + && local.translated_experimental.multi_runner_config["resolved"].runner.group_name == "lane-group" + ) + error_message = "Runner fields must resolve from experimental global defaults before applying lane overrides." } 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." + condition = ( + local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.vpc_id == "vpc-experimental" + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.subnet_ids == tolist(["subnet-lane"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer.enabled + && toset(keys(local.translated_experimental_base.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer.s3 != null + && keys(output.binaries_syncer_map) == ["linux_x64"] + && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + ) + error_message = "Global compute-provider defaults must merge into the required lane selector while lane values take precedence." } assert { - condition = module.runner_stacks["tagged"].scale_up.lambda.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." + condition = ( + !local.translated_experimental.compute_provider.ec2.runner_binaries.enabled + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == "Enabled" + && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.logging.bucket == "runner-binaries-access-logs" + && local.translated_experimental.lambda.artifact.s3.bucket == "experimental-lambda-artifacts" + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.zip == null + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key == "nested-runner-binaries-syncer.zip" + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version == "nested-version" + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.expression == "rate(2 hours)" + && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == "DISABLED" + && keys(output.binaries_syncer_map) == ["linux_x64"] + && output.binaries_syncer_map["linux_x64"].lambda.s3_bucket == "experimental-lambda-artifacts" + && output.binaries_syncer_map["linux_x64"].lambda.s3_key == "nested-runner-binaries-syncer.zip" + && output.binaries_syncer_map["linux_x64"].lambda.s3_object_version == "nested-version" + && output.binaries_syncer_map["linux_x64"].lambda.memory_size == 384 + && output.binaries_syncer_map["linux_x64"].lambda.timeout == 240 + && output.binaries_syncer_map["linux_x64"].bucket.tags["BinaryBucket"] == "global" + ) + error_message = "A lane must be able to enable the globally configured runner-binary distribution and syncer while the shared settings remain global." } assert { - condition = module.runner_stacks["tagged"].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." + condition = ( + length(local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules) == 1 + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].cidr_blocks == tolist(["0.0.0.0/0"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].ipv6_cidr_blocks == tolist(["::/0"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].prefix_list_ids == null + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].from_port == 0 + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].protocol == "-1" + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].security_groups == null + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].self == null + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].to_port == 0 + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].description == null + ) + error_message = "Omitted experimental EC2 egress rules must resolve to the concrete nested allow-all IPv4 and IPv6 default independently of the stable input." } assert { - condition = module.runner_stacks["tagged"].scale_up.role.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." + condition = ( + module.runner_stacks["resolved"].scale_up.lambda.runtime == "nodejs22.x" + && local.translated_experimental.lambda.scale.artifact.zip == null + && local.translated_experimental.lambda.scale.artifact.s3.key == "nested-runners.zip" + && local.translated_experimental.lambda.scale.artifact.s3.object_version == "nested-runners-version" + && local.translated_experimental.lambda.artifact.s3.bucket == "experimental-lambda-artifacts" + && local.translated_experimental.multi_runner_config["resolved"].lambda.zip == null + && local.translated_experimental.multi_runner_config["resolved"].lambda.s3.bucket == "experimental-lambda-artifacts" + && local.translated_experimental.multi_runner_config["resolved"].lambda.s3.key == "nested-runners.zip" + && local.translated_experimental.multi_runner_config["resolved"].lambda.s3.object_version == "nested-runners-version" + && local.translated_experimental.lambda.principals == tolist([{ + type = "Service" + identifiers = tolist(["states.amazonaws.com"]) + }]) + && module.runner_stacks["resolved"].scale_up.lambda.s3_bucket == "experimental-lambda-artifacts" + && module.runner_stacks["resolved"].scale_up.lambda.s3_key == "nested-runners.zip" + && module.runner_stacks["resolved"].scale_up.lambda.s3_object_version == "nested-runners-version" + && module.runner_stacks["resolved"].scale_up.lambda.memory_size == 896 + && module.runner_stacks["resolved"].scale_up.lambda.timeout == 40 + && module.runner_stacks["resolved"].scale_down.lambda.timeout == 75 + && module.runner_stacks["resolved"].pool.lambda.memory_size == 448 + && local.translated_experimental.multi_runner_config["resolved"].lambda.scale_up.event_source_mapping.batch_size == 50 + && module.runner_stacks["resolved"].runner.role.path == "/experimental/" + && module.runner_stacks["resolved"].scale_up.role.path == "/lane-lambda/" + ) + error_message = "V2 values must resolve in lane-over-experimental-global precedence order without stable-input fallback." } assert { - condition = module.runner_stacks["tagged"].runner.role.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." + condition = ( + local.translated_experimental.multi_runner_config["resolved"].queue.delay_webhook_event == 11 + && local.translated_experimental.multi_runner_config["resolved"].queue.job_queue_retention_in_seconds == 172800 + && local.translated_experimental.multi_runner_config["resolved"].queue.visibility_timeout_seconds == 300 + && local.translated_experimental.multi_runner_config["resolved"].queue.redrive_build_queue.enabled + && local.translated_experimental.multi_runner_config["resolved"].queue.redrive_build_queue.maxReceiveCount == 7 + && local.translated_experimental.multi_runner_config["resolved"].queue.tags == tomap({ + GlobalQueue = "global" + LaneQueue = "lane" + Precedence = "lane" + }) + && local.translated_experimental.queue.encryption == var.experimental.queue.encryption + && local.translated_experimental.ssm.kms_key_id == local.translated_experimental.queue.encryption.kms_master_key_id + && aws_sqs_queue.queued_builds["resolved"].delay_seconds == 11 + && aws_sqs_queue.queued_builds["resolved"].message_retention_seconds == 172800 + && aws_sqs_queue.queued_builds["resolved"].visibility_timeout_seconds == 300 + && aws_sqs_queue.queued_builds["resolved"].kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" + && aws_sqs_queue.queued_builds["resolved"].kms_data_key_reuse_period_seconds == 900 + && aws_sqs_queue.queued_builds["resolved"].tags == tomap({ + GlobalQueue = "global" + LaneQueue = "lane" + Precedence = "lane" + }) + && aws_sqs_queue.queued_builds_dlq["resolved"].kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" + && aws_sqs_queue.queued_builds_dlq["resolved"].tags == tomap({ + GlobalQueue = "global" + LaneQueue = "lane" + Precedence = "lane" + }) + ) + error_message = "V2 queue leaves must resolve lane over experimental-global values, merge queue tags, and apply global nested encryption to the build queue and DLQ." } assert { - condition = module.runner_stacks["tagged"].scale_down.lambda.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." + condition = ( + local.translated_experimental.github.app == var.experimental.github.app + && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps + && local.translated_experimental.enterprise_server == var.experimental.enterprise_server + && local.translated_experimental.user_agent == var.experimental.user_agent + && local.translated_experimental.enterprise_server.url == "https://experimental-shared.example.com" + && !local.translated_experimental.enterprise_server.ssl_verify + && local.translated_experimental.user_agent == "experimental-runner-user-agent" + && length(local.translated_experimental.github.additional_apps) == 0 + && local.translated_experimental.multi_runner_config["resolved"].job_retry.enabled + && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" + && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" + && module.runner_stacks["resolved"].scale_down.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" + && module.runner_stacks["resolved"].pool.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" + && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == module.ssm.parameters.github_app_id.name + && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables["NESTED_WATCHER"] == "true" + && output.instance_termination_watcher.lambda.function.runtime == "nodejs22.x" + && output.instance_termination_watcher.lambda.function.architectures == tolist(["arm64"]) + && output.instance_termination_watcher.lambda.function.memory_size == 432 + && output.instance_termination_watcher.lambda.function.timeout == 41 + && output.instance_termination_watcher.lambda.function.s3_bucket == "experimental-lambda-artifacts" + && output.instance_termination_watcher.lambda.function.s3_key == "nested-termination-watcher.zip" + && output.instance_termination_watcher.lambda.function.s3_object_version == "nested-watcher-version" + && length(output.instance_termination_watcher.lambda.function.vpc_config) == 1 + && length(output.instance_termination_watcher.lambda.function.vpc_config[0].subnet_ids) == 0 + && length(output.instance_termination_watcher.lambda.function.vpc_config[0].security_group_ids) == 0 + && output.instance_termination_watcher.lambda_role.path == "/experimental/" + && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" + && output.instance_termination_watcher.lambda.function.environment[0].variables["NESTED_WATCHER"] == "true" + && output.instance_termination_watcher.lambda.function.environment[0].variables["LOG_LEVEL"] == "info" + && output.instance_termination_watcher.lambda_log_group.retention_in_days == 180 + && output.instance_termination_watcher.lambda_log_group.log_group_class == "STANDARD" + && output.instance_termination_handler == null + ) + error_message = "V2 runner stacks and the termination watcher must use nested GitHub, Lambda, role, observability, feature, artifact, sizing, and environment settings without flat component fallback." } assert { - condition = module.runner_stacks["tagged"].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." + condition = ( + local.translated_experimental.compute_provider.ec2.ami.housekeeper.enabled + && length(module.ami_housekeeper) == 1 + && module.ami_housekeeper[0].lambda.runtime == "nodejs22.x" + && module.ami_housekeeper[0].lambda.architectures == tolist(["arm64"]) + && module.ami_housekeeper[0].lambda.memory_size == 448 + && module.ami_housekeeper[0].lambda.timeout == 120 + && module.ami_housekeeper[0].lambda.s3_bucket == "experimental-lambda-artifacts" + && module.ami_housekeeper[0].lambda.s3_key == "nested-ami-housekeeper.zip" + && module.ami_housekeeper[0].lambda.s3_object_version == "nested-ami-housekeeper-version" + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).maxItems == 5 + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).minimumDaysOld == 10 + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).dryRun + && module.ami_housekeeper[0].lambda_role.path == "/experimental/" + ) + error_message = "The v2 AMI housekeeper must be owned by the nested EC2 global and inherit nested Lambda and role globals while ignoring all matching flat component inputs." } assert { - condition = output.runners_map_v2["tagged"].pool == null - error_message = "Experimental v2 must expose a null pool object when no pool configuration is supplied." + condition = ( + local.translated_experimental.lambda.runtime == "nodejs22.x" + && var.experimental.lambda.webhook.memory_size == 384 + && output.webhook.lambda.runtime == "nodejs22.x" + && output.webhook.lambda.architectures == tolist(["arm64"]) + && output.webhook.lambda.memory_size == 384 + && output.webhook.lambda.timeout == 10 + && output.webhook.lambda.s3_bucket == "experimental-lambda-artifacts" + && output.webhook.lambda.s3_key == "nested-webhook.zip" + && output.webhook.lambda.s3_object_version == "nested-webhook-version" + && output.webhook.lambda_role.path == "/experimental/" + && toset(jsondecode(output.webhook.lambda.environment[0].variables["REPOSITORY_ALLOW_LIST"])) == toset(["nested-owner/nested-repository"]) + && output.webhook.lambda.environment[0].variables["QUEUE_SELECTION_STRATEGY"] == "all" + && output.webhook.eventbridge == null + && output.webhook.dispatcher == null + && local.translated_experimental.webhook.matcher_config_parameter_store_tier == "Advanced" + && local.translated_experimental.lambda.webhook.api_gateway_access_log_settings.destination_arn == "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" + && local.translated_experimental.lambda.scale_up.event_source_mapping.batch_size == 25 + && local.translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && !contains(keys(output.webhook.lambda.environment[0].variables), "GHES_URL") + ) + error_message = "The shared webhook must consume nested GitHub, routing, eventbridge, matcher-tier, artifact, API-access-log, Lambda, and role globals without flat-input leakage." + } +} + +run "experimental_v2_layers_observability_and_ssm" { + command = plan + + variables { + tags = { + ModuleOnly = "module" + Precedence = "module" + } + + log_level = "error" + logging_retention_in_days = 90 + logging_kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/flat-logs" + log_class = "STANDARD" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/global-ssm" + ghes_url = "https://flat-observability.example.com" + ghes_ssl_verify = true + user_agent = "flat-observability-user-agent" + + tracing_config = { + mode = null + capture_http_requests = false + capture_error = false + } + + metrics = { + enable = false + namespace = "FlatMetrics" + metric = { + enable_github_app_rate_limit = false + enable_job_retry = false + enable_spot_termination_warning = false + } + } + + ssm_paths = { + root = "flat-root" + app = "shared-app" + runners = "flat-runners" + webhook = "shared-webhook" + } + + parameter_store_tags = { + FlatParameterOnly = "flat-parameter" + Precedence = "flat-parameter" + } + + runners_ssm_housekeeper = { + schedule_expression = "rate(1 day)" + enabled = true + lambda_memory_size = 512 + lambda_timeout = 60 + config = { + tokenPath = "/flat/cleanup/tokens" + minimumDaysOld = 1 + dryRun = false + } + } + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + enterprise_server = { + url = "https://experimental-observability.example.com" + ssl_verify = false + } + user_agent = "experimental-observability-user-agent" + + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + + tags = { + ExperimentalOnly = "experimental" + Precedence = "experimental" + } + + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + + ssm = { + paths = { + root = "/global-ssm" + app = "global-app" + webhook = "global-webhook" + tokens = "global-tokens" + config = "global-config" + } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/global-ssm" + tags = { + GlobalSsmOnly = "global-ssm" + Precedence = "global-ssm" + } + parameters = { + tags = { + GlobalParameterOnly = "global-parameter" + Precedence = "global-parameter" + } + } + housekeeper = { + schedule_expression = "rate(6 hours)" + state = "DISABLED" + tags = { + GlobalHousekeeperOnly = "global-housekeeper" + Precedence = "global-housekeeper" + } + lambda = { + memory_size = 640 + timeout = 70 + } + config = { + tokenPath = "/global/cleanup/tokens" + minimumDaysOld = 6 + dryRun = true + } + } + } + + observability = { + logs = { + level = "debug" + retention_in_days = 30 + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/global-logs" + class = "INFREQUENT_ACCESS" + tags = { + GlobalLogOnly = "global-log" + Precedence = "global-log" + } + } + tracing = { + mode = "Active" + capture_http_requests = true + capture_error = false + } + metrics = { + enable = true + namespace = "GlobalMetrics" + metric = { + enable_github_app_rate_limit = true + enable_job_retry = false + enable_spot_termination_warning = false + } + } + } + + compute_provider = { + ec2 = { + vpc_id = "vpc-global-observability" + subnet_ids = ["subnet-global-observability"] + } + } + + multi_runner_config = { + inherited = { + tags = { + InheritedOnly = "inherited" + Precedence = "inherited" + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "inherited"]] + } + } + + overridden = { + tags = { + OverriddenOnly = "overridden" + Precedence = "overridden" + } + ssm = { + paths = { + root = "/lane-ssm" + tokens = "lane-tokens" + config = "lane-config" + } + tags = { + LaneSsmOnly = "lane-ssm" + Precedence = "lane-ssm" + } + parameters = { + tags = { + LaneParameterOnly = "lane-parameter" + Precedence = "lane-parameter" + } + } + housekeeper = { + schedule_expression = "rate(2 hours)" + state = "ENABLED" + tags = { + LaneHousekeeperOnly = "lane-housekeeper" + Precedence = "lane-housekeeper" + } + lambda = { + memory_size = 768 + timeout = 45 + } + config = { + tokenPath = "/lane/cleanup/tokens" + minimumDaysOld = 2 + dryRun = false + } + } + } + observability = { + logs = { + level = "warn" + retention_in_days = 7 + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/lane-logs" + class = "STANDARD" + tags = { + LaneLogOnly = "lane-log" + Precedence = "lane-log" + } + } + tracing = { + mode = "PassThrough" + capture_http_requests = false + capture_error = true + } + metrics = { + enable = false + namespace = "LaneMetrics" + metric = { + enable_github_app_rate_limit = false + enable_job_retry = true + } + } + } + compute_provider = { + ec2 = { + instance_types = ["c5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "overridden"]] + } + } + } + } + } + + assert { + condition = ( + toset(keys(local.translated_experimental_base.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && !local.translated_experimental.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer.enabled + && local.translated_experimental.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer.s3 == null + && !contains(keys(output.binaries_syncer_map), "linux_x64") + ) + error_message = "A disabled binary syncer must gain a known null S3 value only in the final canonical lane and create no shared syncer resources." + } + + assert { + condition = ( + local.translated_experimental.multi_runner_config["inherited"].observability.logs.level == "debug" + && local.translated_experimental.multi_runner_config["inherited"].observability.logs.retention_in_days == 30 + && local.translated_experimental.multi_runner_config["inherited"].observability.logs.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-logs" + && local.translated_experimental.multi_runner_config["inherited"].observability.logs.class == "INFREQUENT_ACCESS" + && local.translated_experimental.multi_runner_config["inherited"].observability.tracing.mode == "Active" + && local.translated_experimental.multi_runner_config["inherited"].observability.tracing.capture_http_requests + && !local.translated_experimental.multi_runner_config["inherited"].observability.tracing.capture_error + && local.translated_experimental.multi_runner_config["inherited"].observability.metrics.enable + && local.translated_experimental.multi_runner_config["inherited"].observability.metrics.namespace == "GlobalMetrics" + && local.translated_experimental.multi_runner_config["inherited"].observability.metrics.metric.enable_github_app_rate_limit + && !local.translated_experimental.multi_runner_config["inherited"].observability.metrics.metric.enable_job_retry + && local.translated_experimental.observability.metrics.metric.enable_spot_termination + && !local.translated_experimental.observability.metrics.metric.enable_spot_termination_warning + ) + error_message = "A lane omitting observability must inherit every runner-stack logging, tracing, and metrics leaf while watcher-only metric switches remain global." + } + + assert { + condition = ( + local.translated_experimental.multi_runner_config["overridden"].observability.logs.level == "warn" + && local.translated_experimental.multi_runner_config["overridden"].observability.logs.retention_in_days == 7 + && local.translated_experimental.multi_runner_config["overridden"].observability.logs.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/lane-logs" + && local.translated_experimental.multi_runner_config["overridden"].observability.logs.class == "STANDARD" + && local.translated_experimental.multi_runner_config["overridden"].observability.tracing.mode == "PassThrough" + && !local.translated_experimental.multi_runner_config["overridden"].observability.tracing.capture_http_requests + && local.translated_experimental.multi_runner_config["overridden"].observability.tracing.capture_error + && !local.translated_experimental.multi_runner_config["overridden"].observability.metrics.enable + && local.translated_experimental.multi_runner_config["overridden"].observability.metrics.namespace == "LaneMetrics" + && !local.translated_experimental.multi_runner_config["overridden"].observability.metrics.metric.enable_github_app_rate_limit + && local.translated_experimental.multi_runner_config["overridden"].observability.metrics.metric.enable_job_retry + ) + error_message = "Lane observability values must override every lane-owned runner-stack logging, tracing, and metrics leaf." + } + + assert { + condition = ( + local.translated_experimental.multi_runner_config["inherited"].ssm.paths.root == "/global-ssm/inherited" + && local.translated_experimental.multi_runner_config["inherited"].ssm.paths.tokens == "global-tokens" + && local.translated_experimental.multi_runner_config["inherited"].ssm.paths.config == "global-config" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-ssm" + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.schedule_expression == "rate(6 hours)" + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.state == "DISABLED" + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.lambda.memory_size == 640 + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.lambda.timeout == 70 + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.config.tokenPath == "/global/cleanup/tokens" + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.config.minimumDaysOld == 6 + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.config.dryRun + ) + error_message = "A lane omitting SSM values must inherit global paths, KMS ownership, and housekeeper settings." + } + + assert { + condition = ( + local.translated_experimental.multi_runner_config["overridden"].ssm.paths.root == "/lane-ssm/overridden" + && local.translated_experimental.multi_runner_config["overridden"].ssm.paths.tokens == "lane-tokens" + && local.translated_experimental.multi_runner_config["overridden"].ssm.paths.config == "lane-config" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-ssm" + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.schedule_expression == "rate(2 hours)" + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.state == "ENABLED" + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.lambda.memory_size == 768 + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.lambda.timeout == 45 + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.config.tokenPath == "/lane/cleanup/tokens" + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.config.minimumDaysOld == 2 + && !local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.config.dryRun + ) + error_message = "Lane SSM paths and housekeeper leaves must override globals while the global KMS key ID remains shared by every lane." + } + + assert { + condition = ( + module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-observability.example.com" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-observability-user-agent" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GlobalMetrics" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "true" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && module.runner_stacks["inherited"].scale_up.lambda.tracing_config[0].mode == "Active" + && module.runner_stacks["inherited"].scale_up.log_group.retention_in_days == 30 + && module.runner_stacks["inherited"].scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-logs" + && module.runner_stacks["inherited"].scale_up.log_group.log_group_class == "INFREQUENT_ACCESS" + && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LaneMetrics" + && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "false" + && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" + && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "true" + && module.runner_stacks["overridden"].scale_up.lambda.tracing_config[0].mode == "PassThrough" + && module.runner_stacks["overridden"].scale_up.log_group.retention_in_days == 7 + && module.runner_stacks["overridden"].scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/lane-logs" + && module.runner_stacks["overridden"].scale_up.log_group.log_group_class == "STANDARD" + ) + error_message = "Resolved global GitHub connection settings and global/lane observability must reach runner-stack Lambda and log-group resources." + } + + assert { + condition = ( + module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/global-ssm/inherited/global-tokens" + && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/global-ssm/inherited/global-config" + && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/lane-ssm/overridden/lane-tokens" + && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/lane-ssm/overridden/lane-config" + && tomap({ + for tag in jsondecode(module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + tag.Key => tag.Value + }) == tomap({ + ExperimentalOnly = "experimental" + InheritedOnly = "inherited" + GlobalSsmOnly = "global-ssm" + GlobalParameterOnly = "global-parameter" + Precedence = "global-parameter" + "ghr:environment" = "github-actions" + }) + && tomap({ + for tag in jsondecode(module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + tag.Key => tag.Value + }) == tomap({ + ExperimentalOnly = "experimental" + OverriddenOnly = "overridden" + GlobalSsmOnly = "global-ssm" + LaneSsmOnly = "lane-ssm" + GlobalParameterOnly = "global-parameter" + LaneParameterOnly = "lane-parameter" + Precedence = "lane-parameter" + "ghr:environment" = "github-actions" + }) + ) + error_message = "Resolved lane roots and layered SSM parameter tags must reach runner-stack runtime configuration." + } + + assert { + condition = ( + local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.tags == tomap({ + GlobalHousekeeperOnly = "global-housekeeper" + Precedence = "global-housekeeper" + }) + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.tags == tomap({ + GlobalHousekeeperOnly = "global-housekeeper" + LaneHousekeeperOnly = "lane-housekeeper" + Precedence = "lane-housekeeper" + }) + && module.runner_stacks["inherited"].scale_up.log_group.tags == tomap({ + ExperimentalOnly = "experimental" + InheritedOnly = "inherited" + GlobalLogOnly = "global-log" + Precedence = "global-log" + "ghr:environment" = "github-actions" + }) + && module.runner_stacks["overridden"].scale_up.log_group.tags == tomap({ + ExperimentalOnly = "experimental" + OverriddenOnly = "overridden" + GlobalLogOnly = "global-log" + LaneLogOnly = "lane-log" + Precedence = "lane-log" + "ghr:environment" = "github-actions" + }) + ) + error_message = "Global and lane observability and SSM housekeeper tags must merge with narrower scopes taking precedence." + } + + assert { + condition = ( + var.ssm_paths.root == "flat-root" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-ssm" + && var.kms_key_arn == local.translated_experimental.ssm.kms_key_id + && output.webhook.lambda.runtime == "nodejs24.x" + && output.webhook.lambda.architectures == tolist(["arm64"]) + && output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == "/global-ssm/global-webhook/runner-matcher-config" + && output.webhook.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && output.webhook.lambda.tracing_config[0].mode == "Active" + && output.webhook.lambda_log_group.retention_in_days == 30 + && output.webhook.lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-logs" + && output.webhook.lambda_log_group.log_group_class == "INFREQUENT_ACCESS" + && output.webhook.lambda.tags["ExperimentalOnly"] == "experimental" + && !contains(keys(output.webhook.lambda.tags), "ModuleOnly") + ) + error_message = "The shared webhook must use translated global SSM/KMS, observability, Lambda, and tag inputs rather than flat compatibility values." + } + + assert { + condition = output.ssm_parameters.id.name == "/global-ssm/global-app/github_app_id" + error_message = "Shared SSM parameters must use translated global root and app paths in v2 mode." + } +} + +run "experimental_v2_requires_global_github_app" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + override_module { + target = module.ssm + outputs = { + parameters = { + github_app_id = { name = "/mock/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/app-id" } + github_app_key_base64 = { name = "/mock/key", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/key" } + github_app_webhook_secret = { name = "/mock/webhook-secret", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/webhook-secret" } + } + additional_app_parameters = [] + } + } + + variables { + experimental = { + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-github-app" + subnet_ids = ["subnet-missing-github-app"] + } + } + multi_runner_config = { + missing_github_app = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "missing-github-app"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_incomplete_global_github_app" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + override_module { + target = module.ssm + outputs = { + parameters = { + github_app_id = { name = "/mock/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/app-id" } + github_app_key_base64 = { name = "/mock/key", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/key" } + github_app_webhook_secret = { name = "/mock/webhook-secret", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/webhook-secret" } + } + additional_app_parameters = [] + } + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-incomplete-github-app" + subnet_ids = ["subnet-incomplete-github-app"] + } + } + multi_runner_config = { + incomplete_github_app = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "incomplete-github-app"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_incomplete_additional_github_app" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + override_module { + target = module.ssm + outputs = { + parameters = { + github_app_id = { name = "/mock/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/app-id" } + github_app_key_base64 = { name = "/mock/key", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/key" } + github_app_webhook_secret = { name = "/mock/webhook-secret", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/mock/webhook-secret" } + } + additional_app_parameters = [] + } + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + additional_apps = [{ + id = "incomplete-additional-app" + }] + } + compute_provider = { + ec2 = { + vpc_id = "vpc-incomplete-additional-app" + subnet_ids = ["subnet-incomplete-additional-app"] + } + } + multi_runner_config = { + incomplete_additional_app = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "incomplete-additional-app"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_prefers_nested_primary_github_app_over_flat" { + command = plan + + variables { + experimental = { + github = { + app = { + id = "different-app-id" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-mismatched-primary-app" + subnet_ids = ["subnet-mismatched-primary-app"] + } + } + multi_runner_config = { + mismatched_primary_app = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-primary-app"]] + } + } + } + } + } + + assert { + condition = ( + var.github_app.id == "123456" + && local.translated_experimental.github.app.id == "different-app-id" + && length(local.github_app_parameters.id) == 1 + && output.ssm_parameters.id.name == "/github-action-runners/github-actions/app/github_app_id" + ) + error_message = "V2 must use the nested primary GitHub App and generated parameter references even when the stable flat app differs." + } +} + +run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { + command = plan + + variables { + additional_github_apps = [{ + id_ssm = { + name = "/github-runner/flat-additional-app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/flat-additional-app-id" + } + key_base64_ssm = { + name = "/github-runner/flat-additional-key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/flat-additional-key-base64" + } + }] + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-mismatched-additional-apps" + subnet_ids = ["subnet-mismatched-additional-apps"] + } + } + multi_runner_config = { + mismatched_additional_apps = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-additional-apps"]] + } + } + } + } + } + + assert { + condition = ( + length(var.additional_github_apps) == 1 + && length(local.translated_experimental.github.additional_apps) == 0 + && length(local.github_app_parameters.id) == 1 + && !contains(keys(output.ssm_parameters), "github_app_id_1") + ) + error_message = "V2 must ignore stable flat additional GitHub Apps when the nested additional-app list is empty." + } +} + +run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled" { + command = plan + + variables { + ghes_url = "https://flat-disabled-deregistration.example.com" + instance_termination_watcher = { + enable = false + enable_runner_deregistration = true + } + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + enterprise_server = { + url = "https://experimental-disabled-deregistration.example.com" + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-disabled-deregistration" + subnet_ids = ["subnet-disabled-deregistration"] + instance_termination_watcher = { + enabled = true + enable_runner_deregistration = false + artifact = { + zip = "README.md" + } + } + } + } + multi_runner_config = { + disabled_deregistration = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "disabled-deregistration"]] + } + } + } + } + } + + assert { + condition = ( + !var.instance_termination_watcher.enable + && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled + && !local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration + && output.instance_termination_watcher != null + && module.runner_stacks["disabled_deregistration"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-disabled-deregistration.example.com" + ) + error_message = "The termination watcher must remain enabled while the v2 runner stack uses the translated enterprise-server URL when deregistration is disabled." + } +} + +run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { + command = plan + + variables { + ghes_url = "https://flat-watcher.example.com" + instance_termination_watcher = { + enable = false + enable_runner_deregistration = false + } + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + enterprise_server = { + url = "https://experimental-watcher.example.com" + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-mismatched-watcher-ghes" + subnet_ids = ["subnet-mismatched-watcher-ghes"] + instance_termination_watcher = { + enabled = true + enable_runner_deregistration = true + artifact = { + zip = "README.md" + } + } + } + } + multi_runner_config = { + mismatched_watcher_ghes = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-watcher-ghes"]] + } + } + } + } + } + + assert { + condition = ( + !var.instance_termination_watcher.enable + && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled + && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration + && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" + && module.runner_stacks["mismatched_watcher_ghes"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" + && var.ghes_url == "https://flat-watcher.example.com" + ) + error_message = "An enabled v2 termination watcher must use the translated enterprise-server URL instead of a deliberately different flat GHES URL." + } +} + +run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { + command = plan + + variables { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/flat-only" + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + vpc_id = "vpc-flat-only-kms" + subnet_ids = ["subnet-flat-only-kms"] + } + } + multi_runner_config = { + flat_only = { + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "flat-only-kms"]] + } + } + } + } + } + + assert { + condition = ( + var.kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/flat-only" + && local.translated_experimental.ssm.kms_key_id == null + && output.ssm_parameters.id.name == "/github-action-runners/github-actions/app/github_app_id" + ) + error_message = "V2 shared SSM resources must ignore a flat-only KMS key when the nested KMS key ID is absent." + } +} + +run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { + command = plan + + variables { + kms_key_arn = null + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + ssm = { + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-only" + } + compute_provider = { + ec2 = { + vpc_id = "vpc-experimental-only-kms" + subnet_ids = ["subnet-experimental-only-kms"] + } + } + multi_runner_config = { + experimental_only = { + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "experimental-only-kms"]] + } + } + } + } + } + + assert { + condition = ( + var.kms_key_arn == null + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/experimental-only" + && output.ssm_parameters.id.name == "/github-action-runners/github-actions/app/github_app_id" + ) + error_message = "V2 shared SSM resources must accept a nested KMS key ID without a flat KMS key." + } +} + +run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { + command = plan + + variables { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/flat-mismatch" + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + ssm = { + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-mismatch" + } + compute_provider = { + ec2 = { + vpc_id = "vpc-mismatched-kms" + subnet_ids = ["subnet-mismatched-kms"] + } + } + multi_runner_config = { + mismatched = { + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-kms"]] + } + } + } + } + } + + assert { + condition = ( + var.kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/flat-mismatch" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/experimental-mismatch" + ) + error_message = "V2 shared SSM resources must prefer the nested KMS key ID over a deliberately different flat KMS key." + } +} + +run "experimental_v2_external_role_ignores_global_iam_management" { + command = plan + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + runner = { + iam = { + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + additional_trust_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [] + }) + } + } + + compute_provider = { + ec2 = { + vpc_id = "vpc-external-role" + subnet_ids = ["subnet-external-role"] + } + } + + multi_runner_config = { + external = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external-runner" + } + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "external"]] + } + } + } + } + } + + assert { + condition = ( + local.translated_experimental.multi_runner_config["external"].runner.iam.role.arn == "arn:aws:iam::123456789012:role/external-runner" + && length(local.translated_experimental.multi_runner_config["external"].runner.iam.managed_policy_arns) == 0 + && local.translated_experimental.multi_runner_config["external"].runner.iam.additional_trust_policy_json == null + ) + error_message = "A lane selecting an external runner role must not inherit global managed policies or trust-policy additions." + } +} + +run "experimental_v2_rejects_explicit_iam_management_with_external_role" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + override_module { + target = module.runner_stacks["invalid"] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + runner = { + iam = { + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + } + } + + compute_provider = { + ec2 = { + vpc_id = "vpc-invalid-external-role" + subnet_ids = ["subnet-invalid-external-role"] + } + } + + multi_runner_config = { + invalid = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external-runner" + } + managed_policy_arns = { + explicit = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" + } + additional_trust_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [] + }) + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "invalid"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +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 = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + + tags = { + ExperimentalOnly = "experimental" + Precedence = "experimental" + } + + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + tags = { + ExperimentalLambdaOnly = "experimental-lambda" + Precedence = "experimental-lambda" + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + + compute_provider = { + ec2 = { + vpc_id = "vpc-tagged" + subnet_ids = ["subnet-tagged"] + } + } + + multi_runner_config = { + 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" + } + scale_up = { + tags = { + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + } + } + scale_down = { + tags = { + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + } + } + } + + queue = { + redrive_build_queue = { + enabled = true + maxReceiveCount = 3 + } + tags = { + SharedQueueOnly = "shared-queue" + Precedence = "shared-queue" + } + } + + observability = { + logs = { + tags = { + SharedLogOnly = "shared-log" + Precedence = "shared-log" + } + } + } + + compute_provider = { + 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({ + ExperimentalOnly = "experimental" + 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({ + ExperimentalOnly = "experimental" + 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"].scale_up.lambda.tags == tomap({ + ExperimentalOnly = "experimental" + RunnerConfigOnly = "runner-config" + ExperimentalLambdaOnly = "experimental-lambda" + ConfigLambdaOnly = "config-lambda" + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + "ghr:environment" = "github-actions" + }) + 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"].scale_up.log_group.tags == tomap({ + ExperimentalOnly = "experimental" + RunnerConfigOnly = "runner-config" + SharedLogOnly = "shared-log" + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + "ghr:environment" = "github-actions" + }) + 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"].scale_up.role.tags == tomap({ + ExperimentalOnly = "experimental" + RunnerConfigOnly = "runner-config" + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + "ghr:environment" = "github-actions" + }) + 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"].runner.role.tags == tomap({ + ExperimentalOnly = "experimental" + RunnerConfigOnly = "runner-config" + RunnerOnly = "runner" + Precedence = "runner" + "ghr:environment" = "github-actions" + }) + error_message = "Runner role tags must merge global, runner-configuration, and runner-component tags in that precedence order." + } + + assert { + condition = module.runner_stacks["tagged"].scale_down.lambda.tags == tomap({ + ExperimentalOnly = "experimental" + RunnerConfigOnly = "runner-config" + ExperimentalLambdaOnly = "experimental-lambda" + ConfigLambdaOnly = "config-lambda" + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + "ghr:environment" = "github-actions" + }) + error_message = "Scale-down Lambda tags must preserve shared layers before applying scale-down component tags." + } + + assert { + condition = module.runner_stacks["tagged"].scale_down.log_group.tags == tomap({ + ExperimentalOnly = "experimental" + RunnerConfigOnly = "runner-config" + SharedLogOnly = "shared-log" + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + "ghr:environment" = "github-actions" + }) + error_message = "Scale-down log-group tags must preserve shared log tags before applying scale-down component tags." + } + + assert { + condition = output.runners_map_v2["tagged"].pool == null + error_message = "Experimental v2 must expose a null pool object when no pool configuration is supplied." + } +} + +run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale_up = { + timeout = 40 + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-invalid-visibility" + subnet_ids = ["subnet-invalid-visibility"] + } + } + + multi_runner_config = { + invalid_visibility = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + queue = { + visibility_timeout_seconds = 239 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_conflicting_queue_encryption" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + queue = { + encryption = { + kms_data_key_reuse_period_seconds = null + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/conflicting-queue" + sqs_managed_sse_enabled = true + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-invalid-encryption" + subnet_ids = ["subnet-invalid-encryption"] + } + } + + multi_runner_config = { + invalid_encryption = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_redrive_without_max_receive_count" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + queue = { + redrive_build_queue = { + enabled = true + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-redrive-max" + subnet_ids = ["subnet-missing-redrive-max"] + } + } + multi_runner_config = { + missing_redrive_max = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-nonpositive-redrive-max" + subnet_ids = ["subnet-nonpositive-redrive-max"] + } + } + multi_runner_config = { + nonpositive_redrive_max = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + queue = { + redrive_build_queue = { + enabled = true + maxReceiveCount = 0 + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_runner_artifact_zip_and_s3" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + scale = { + artifact = { + zip = "README.md" + s3 = { + key = "runners.zip" + } + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-conflicting-runner-artifact" + subnet_ids = ["subnet-conflicting-runner-artifact"] + } + } + multi_runner_config = { + conflicting_runner_artifact = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_runner_artifact_bucket_without_key" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + scale = { + artifact = { + s3 = { + key = null + } + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-runner-artifact-key" + subnet_ids = ["subnet-missing-runner-artifact-key"] + } + } + multi_runner_config = { + missing_runner_artifact_key = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-runner-artifact-bucket" + subnet_ids = ["subnet-missing-runner-artifact-bucket"] + } + } + multi_runner_config = { + missing_runner_artifact_bucket = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_runner_binaries_artifact_zip_and_s3" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-conflicting-binary-artifact" + subnet_ids = ["subnet-conflicting-binary-artifact"] + runner_binaries = { + syncer = { + artifact = { + zip = "runner-binaries-syncer.zip" + s3 = { + key = "runner-binaries-syncer.zip" + } + } + } + } + } + } + multi_runner_config = { + conflicting_binary_artifact = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_runner_binaries_logging_prefix_without_bucket" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-binary-logging-prefix" + subnet_ids = ["subnet-binary-logging-prefix"] + runner_binaries = { + s3 = { + logging = { + prefix = "runner-binaries/" + } + } + } + } + } + multi_runner_config = { + binary_logging_prefix = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { + command = plan + + variables { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/shared-control-plane" + + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + webhook = { + artifact = { + zip = "README.md" + } + } + } + queue = { + encryption = { + kms_data_key_reuse_period_seconds = 300 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/queue-only" + sqs_managed_sse_enabled = null + } + } + ssm = { + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/shared-control-plane" + } + compute_provider = { + ec2 = { + vpc_id = "vpc-mismatched-queue-kms" + subnet_ids = ["subnet-mismatched-queue-kms"] + } + } + + multi_runner_config = { + mismatched_queue_kms = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + assert { + condition = ( + local.translated_experimental.queue.encryption.kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/shared-control-plane" + && aws_sqs_queue.queued_builds["mismatched_queue_kms"].kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" + ) + error_message = "V2 queue and shared SSM resources must accept and independently use distinct customer-managed KMS keys." } } run "experimental_v2_rejects_empty_compute_provider" { command = plan + plan_options { + target = [terraform_data.validate_experimental] + } + variables { experimental = { - multi_runner_config_v2 = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-invalid-provider" + subnet_ids = ["subnet-invalid-provider"] + } + } + + multi_runner_config = { microvm = { runner = { os = "linux" @@ -554,5 +4071,56 @@ run "experimental_v2_rejects_empty_compute_provider" { } } - expect_failures = [var.experimental] + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_invalid_ssm_housekeeper_state" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-invalid-housekeeper" + subnet_ids = ["subnet-invalid-housekeeper"] + } + } + + multi_runner_config = { + invalid = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 2 + } + ssm = { + housekeeper = { + state = "PAUSED" + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] } diff --git a/modules/multi-runner/validations.experimental.tf b/modules/multi-runner/validations.experimental.tf new file mode 100644 index 0000000000..5e7534f382 --- /dev/null +++ b/modules/multi-runner/validations.experimental.tf @@ -0,0 +1,349 @@ +resource "terraform_data" "validate_experimental" { + lifecycle { + precondition { + condition = ( + length(var.experimental.multi_runner_config) == 0 || + var.experimental.github.app != null + ) + error_message = "experimental.github.app is required when experimental.multi_runner_config is not empty." + } + + precondition { + condition = !local.use_multi_runner_config_v2 ? true : ( + var.experimental.github.app == null ? true : ( + (var.experimental.github.app.key_base64 != null || var.experimental.github.app.key_base64_ssm != null) && + (var.experimental.github.app.id != null || var.experimental.github.app.id_ssm != null) && + (var.experimental.github.app.webhook_secret != null || var.experimental.github.app.webhook_secret_ssm != null) + ) + ) + error_message = "experimental.github.app must set one value from each pair: key_base64 or key_base64_ssm, id or id_ssm, and webhook_secret or webhook_secret_ssm." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || alltrue([ + for app in var.experimental.github.additional_apps : + (app.key_base64 != null || app.key_base64_ssm != null) && + (app.id != null || app.id_ssm != null) + ]) + error_message = "Each experimental.github.additional_apps entry must provide either key_base64 or key_base64_ssm, and either id or id_ssm." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + 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 exactly one compute-provider block. Supported compute-provider blocks: ec2." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + try(coalesce(runner_config.runner.os, var.experimental.runner.os), null) != null && + try(coalesce(runner_config.runner.architecture, var.experimental.runner.architecture), null) != null && + try(coalesce(runner_config.runner.maximum_count, var.experimental.runner.maximum_count), null) != null + ]) + error_message = "Each experimental runner configuration must resolve runner.os, runner.architecture, and runner.maximum_count from the configuration or experimental global runner defaults." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.compute_provider.ec2 == null ? true : ( + try(coalesce(runner_config.compute_provider.ec2.vpc_id, var.experimental.compute_provider.ec2.vpc_id), null) != null && + try(coalesce(runner_config.compute_provider.ec2.subnet_ids, var.experimental.compute_provider.ec2.subnet_ids), null) != null + ) + ]) + error_message = "Each experimental EC2 runner configuration must resolve compute_provider.ec2.vpc_id and subnet_ids from the lane or experimental global EC2 defaults. Flat v1 inputs are not inherited by v2." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + coalesce( + runner_config.queue.visibility_timeout_seconds, + var.experimental.queue.visibility_timeout_seconds, + ) >= 6 * coalesce( + runner_config.lambda.scale_up.timeout, + var.experimental.lambda.scale_up.timeout, + ) + ]) + error_message = "Each experimental queue.visibility_timeout_seconds must be at least six times the resolved lambda.scale_up.timeout." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + ( + var.experimental.queue.encryption.sqs_managed_sse_enabled != null && + var.experimental.queue.encryption.kms_master_key_id == null && + var.experimental.queue.encryption.kms_data_key_reuse_period_seconds == null + ) || ( + var.experimental.queue.encryption.sqs_managed_sse_enabled == null && + var.experimental.queue.encryption.kms_master_key_id != null + ) + ) + error_message = "Invalid experimental.queue.encryption configuration. Use SQS-managed encryption, disable it, or configure a KMS key." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + try(contains(["linux", "osx", "windows"], coalesce(runner_config.runner.os, var.experimental.runner.os)), false) + ]) + error_message = "Experimental runner.os must be linux, osx, or windows." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + ( + var.experimental.lambda.architecture == null || + try(contains(["arm64", "x86_64"], var.experimental.lambda.architecture), false) + ) && alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.lambda.architecture == null || try(contains(["arm64", "x86_64"], runner_config.lambda.architecture), false) + ]) + ) + error_message = "Experimental lambda.architecture must be arm64 or x86_64." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + ( + var.experimental.observability.logs.level == null || + try(contains(["debug", "info", "warn", "error"], var.experimental.observability.logs.level), false) + ) && alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.observability.logs.level == null || + try(contains(["debug", "info", "warn", "error"], runner_config.observability.logs.level), false) + ]) + ) + error_message = "Experimental observability.logs.level must be debug, info, warn, or error." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + ( + var.experimental.observability.logs.class == null || + try(contains(["STANDARD", "INFREQUENT_ACCESS"], var.experimental.observability.logs.class), false) + ) && alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.observability.logs.class == null || + try(contains(["STANDARD", "INFREQUENT_ACCESS"], runner_config.observability.logs.class), false) + ]) + ) + error_message = "Experimental observability.logs.class must be STANDARD or INFREQUENT_ACCESS." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + ( + var.experimental.ssm.paths.root == null || + try(startswith(var.experimental.ssm.paths.root, "/"), false) + ) && alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.ssm.paths.root == null || try(startswith(runner_config.ssm.paths.root, "/"), false) + ]) + ) + error_message = "Experimental ssm.paths.root base paths must start with '/'. The lane key is appended during normalization." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + ( + var.experimental.ssm.housekeeper.state == null || + try(contains(["DISABLED", "ENABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], var.experimental.ssm.housekeeper.state), false) + ) && alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.ssm.housekeeper.state == null || + try(contains(["DISABLED", "ENABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], runner_config.ssm.housekeeper.state), false) + ]) + ) + error_message = "Experimental ssm.housekeeper.state must be DISABLED, ENABLED, or ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || contains( + ["Disabled", "Enabled", "Suspended"], + var.experimental.compute_provider.ec2.runner_binaries.s3.versioning, + ) + error_message = "experimental.compute_provider.ec2.runner_binaries.s3.versioning must be Disabled, Enabled, or Suspended." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || contains( + ["DISABLED", "ENABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], + var.experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state, + ) + error_message = "experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state must be DISABLED, ENABLED, or ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || contains( + ["first", "random", "all"], + var.experimental.webhook.queue_selection_strategy, + ) + error_message = "experimental.webhook.queue_selection_strategy must be first, random, or all." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || contains( + ["Standard", "Advanced"], + var.experimental.webhook.matcher_config_parameter_store_tier, + ) + error_message = "experimental.webhook.matcher_config_parameter_store_tier must be Standard or Advanced." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + !( + var.experimental.lambda.scale.artifact.zip != null && + var.experimental.lambda.scale.artifact.s3 != null + ) && ( + var.experimental.lambda.scale.artifact.s3 == null || ( + var.experimental.lambda.artifact.s3.bucket != null && + try(var.experimental.lambda.scale.artifact.s3.key != null, false) + ) + ) + ) + error_message = "experimental.lambda.scale.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + !( + var.experimental.lambda.webhook.artifact.zip != null && + var.experimental.lambda.webhook.artifact.s3 != null + ) && ( + var.experimental.lambda.webhook.artifact.s3 == null || ( + var.experimental.lambda.artifact.s3.bucket != null && + try(var.experimental.lambda.webhook.artifact.s3.key != null, false) + ) + ) + ) + error_message = "experimental.lambda.webhook.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + !( + var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip != null && + var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3 != null + ) && ( + var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3 == null || ( + var.experimental.lambda.artifact.s3.bucket != null && + try(var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key != null, false) + ) + ) + ) + error_message = "experimental.compute_provider.ec2.instance_termination_watcher.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + !( + var.experimental.compute_provider.ec2.ami.housekeeper.artifact.zip != null && + var.experimental.compute_provider.ec2.ami.housekeeper.artifact.s3 != null + ) && ( + var.experimental.compute_provider.ec2.ami.housekeeper.artifact.s3 == null || ( + var.experimental.lambda.artifact.s3.bucket != null && + try(var.experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key != null, false) + ) + ) + ) + error_message = "experimental.compute_provider.ec2.ami.housekeeper.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || !( + var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.zip != null && + var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 != null + ) + error_message = "experimental.compute_provider.ec2.runner_binaries.syncer.artifact must set at most one of zip or s3." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 == null ? true : ( + var.experimental.lambda.artifact.s3.bucket != null && + var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key != null + ) + ) + error_message = "experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 requires experimental.lambda.artifact.s3.bucket and a non-null key." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || contains( + ["AES256", "aws:kms", "aws:kms:dsse"], + var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm, + ) + error_message = "experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm must be AES256, aws:kms, or aws:kms:dsse." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + !var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled ? ( + var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id == null + ) : ( + var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id == null || + contains( + ["aws:kms", "aws:kms:dsse"], + var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm, + ) + ) + ) + error_message = "experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm must be aws:kms or aws:kms:dsse when kms_master_key_id is set." + } + + precondition { + condition = alltrue([ + for runner_config in values(local.translated_experimental_base.multi_runner_config) : + !runner_config.queue.redrive_build_queue.enabled || try( + runner_config.queue.redrive_build_queue.maxReceiveCount > 0, + false, + ) + ]) + error_message = "An enabled experimental queue.redrive_build_queue requires maxReceiveCount greater than zero." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + var.experimental.compute_provider.ec2.runner_binaries.s3.logging.prefix == null || + var.experimental.compute_provider.ec2.runner_binaries.s3.logging.bucket != null + ) + error_message = "experimental.compute_provider.ec2.runner_binaries.s3.logging.prefix requires logging.bucket." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + try(coalesce(runner_config.runner.iam.role, var.experimental.runner.iam.role), null) == null || + length( + runner_config.runner.iam.role != null ? ( + runner_config.runner.iam.managed_policy_arns != null ? runner_config.runner.iam.managed_policy_arns : {} + ) : ( + runner_config.runner.iam.managed_policy_arns != null ? runner_config.runner.iam.managed_policy_arns : ( + var.experimental.runner.iam.managed_policy_arns != null ? var.experimental.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." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + try(coalesce(runner_config.runner.iam.role, var.experimental.runner.iam.role), null) == null || + ( + runner_config.runner.iam.role != null ? runner_config.runner.iam.additional_trust_policy_json : + try(coalesce(runner_config.runner.iam.additional_trust_policy_json, var.experimental.runner.iam.additional_trust_policy_json), null) + ) == null + ]) + error_message = "runner.iam.additional_trust_policy_json cannot be set with an external runner.iam.role because external roles are not managed by this module." + } + + } +} diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index da5860f01f..492e9c2da4 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -2,235 +2,897 @@ 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. To opt into v2, leave `multi_runner_config` empty and populate this map. When this map is empty, stable `multi_runner_config` entries continue to use the unchanged `runners` module. Populating both maps in the same module instance is unsupported. + Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module. - Each `multi_runner_config_v2` entry supports the following nested fields: + Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map. + Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role. + Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply. - - `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. - - `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. - - `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. + Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape. + + - `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. + - `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null. + - `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null. + - `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally. + - `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally. + - `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`. + - `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`. + - `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`. + - `runner.group_name`: Default GitHub runner group. The default is `Default`. + - `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string. + - `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`. + - `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`. + - `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally. + - `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`. + - `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode. + - `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`. + - `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`. + - `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string. + - `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string. + - `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning. + - `runner.iam.role.arn`: ARN of the externally managed runner role. + - `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map. + - `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value. + - `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`. + - `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`. + - `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object. + - `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly. + - `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`. + - `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter. + - `github.app.key_base64_ssm.name`: Name of the existing private-key parameter. + - `github.app.id`: GitHub App ID supplied directly. + - `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`. + - `github.app.id_ssm.arn`: ARN of the existing app-ID parameter. + - `github.app.id_ssm.name`: Name of the existing app-ID parameter. + - `github.app.webhook_secret`: GitHub App webhook secret supplied directly. + - `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`. + - `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter. + - `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter. + - `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`. + - `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app. + - `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`. + - `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter. + - `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter. + - `github.additional_apps[].id`: Additional GitHub App ID supplied directly. + - `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`. + - `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter. + - `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter. + - `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app. + - `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper. + - `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter. + - `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter. + - `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering. + - `enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null. + - `enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`. + - `user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`. + - `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job. + - `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. + - `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events. + - `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks. + - `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`. + - `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`. + - `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present. + - `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used. + - `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null. + - `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key. + - `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive. + - `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null. + - `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks. + - `lambda.principals[].type`: IAM principal type. + - `lambda.principals[].identifiers`: IAM principal identifiers for the type. + - `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`. + - `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`. + - `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. + - `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`. + - `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`. + - `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`. + - `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`. + - `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. + - `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. + - `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. + - `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`. + - `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`. + - `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`. + - `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. + - `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`. + - `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. + - `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`. + - `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. + - `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period. + - `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. + - `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`. + - `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null. + - `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key. + - `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive. + - `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null. + - `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block. + - `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs. + - `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format. + - `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`. + - `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`. + - `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`. + - `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`. + - `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`. + - `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency. + - `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component. + - `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `lambda.pool.config[].size`: Desired runner-pool size for the schedule. + - `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`. + - `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null. + - `lambda.pool.tags`: Default tags for pool resources. The default is `{}`. + - `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`. + - `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`. + - `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it. + - `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`. + - `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled. + - `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`. + - `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode. + - `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`. + - `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK. + - `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`. + - `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths. + - `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`. + - `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`. + - `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`. + - `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`. + - `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters. + - `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`. + - `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`. + - `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`. + - `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`. + - `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`. + - `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`. + - `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`. + - `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path. + - `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`. + - `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`. + - `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`. + - `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`. + - `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. + - `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`. + - `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead. + - `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks. + - `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`. + - `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`. + - `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`. + - `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`. + - `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`. + - `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`. + - `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`. + - `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`. + - `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally. + - `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally. + - `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`. + - `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`. + - `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule. + - `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule. + - `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule. + - `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range. + - `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol. + - `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule. + - `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. + - `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range. + - `compute_provider.ec2.egress_rules[].description`: Optional egress rule description. + - `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`. + - `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned. + - `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null. + - `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null. + - `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`. + - `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence. + - `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda. + - `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance. + - `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module. + - `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum. + - `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`. + - `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`. + - `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates. + - `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters. + - `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`. + - `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null. + - `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. + - `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive. + - `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null. + - `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`. + - `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`. + - `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`. + - `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher. + - `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance. + - `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources. + - `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources. + - `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources. + - `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`. + - `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null. + - `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. + - `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive. + - `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null. + - `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module. + - `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module. + - `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair. + - `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances. + - `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket. + - `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket. + - `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false. + - `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null. + - `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms. + - `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK. + - `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`. + - `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead. + - `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket. + - `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource. + - `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`. + - `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda. + - `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null. + - `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. + - `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present. + - `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null. + - `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks. + - `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`. + - `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`. + - `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda. + - `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`. + - `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`. + + Each `experimental.multi_runner_config` entry supports the following nested fields: + + - `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks. + - `multi_runner_config[].runner.os`: Runner operating system. + - `multi_runner_config[].runner.architecture`: Runner distribution architecture. + - `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale. + - `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered. + - `multi_runner_config[].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. + - `multi_runner_config[].runner.group_name`: GitHub runner group used during registration. + - `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names. + - `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider. + - `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false. + - `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration. + - `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode. + - `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode. + - `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. + - `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`. + - `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook. + - `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook. + - `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed. + - `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role. + - `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`. + - `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`. + - `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role. + - `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. + - `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used. + - `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions. + - `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions. + - `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions. + - `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions. + - `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map. + - `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`. + - `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`. + - `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default. + - `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default. + - `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations. + - `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. + - `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. + - `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value. + - `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled. + - `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map. + - `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB. + - `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. + - `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode. + - `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. + - `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB. + - `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. + - `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down. + - `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected. + - `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. + - `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations. + - `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`. + - `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period. + - `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. + - `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. + - `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds. + - `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component. + - `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule. + - `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners. + - `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. + - `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. + - `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. + - `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. + - `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. + - `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. + - `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. + - `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. + - `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. + - `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`. + - `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration. + - `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration. + - `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`. + - `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`. + - `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper. + - `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`. + - `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values. + - `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB. + - `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds. + - `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path. + - `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. + - `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. + - `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions. + - `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources. + - `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups. + - `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources. + - `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map. + - `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks. + - `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper. + - `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper. + - `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics. + - `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics. + - `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics. + - `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics. + - `multi_runner_config[].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. + - `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration. + - `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter. + - `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. + - `multi_runner_config[].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. + - `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply. + - `multi_runner_config[].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. + - `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB. + - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type. + - `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role. + - `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types. + - `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances. + - `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow. + - `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. + - `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`. + - `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances. + - `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access. + - `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data. + - `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template. + - `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template. + - `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template. + - `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template. + - `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs. + - `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity. + - `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price. + - `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`. + - `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type. + - `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions. + - `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances. + - `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true. + - `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description. + - `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile. + - `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances. + - `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances. + - `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`. + - `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure. + - `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures. + - `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value. + - `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value. + - `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance. + - `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core. + - `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. + - `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types. + - `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting. + - `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed. + - `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID. + - `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name. + - `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID. + - `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement. + - `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value. + - `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy. + - `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number. + - `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration. + - `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners. + - `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent. + - `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing. + - `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true. + - `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent. + - `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template. + - `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file. + - `multi_runner_config[].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. + - `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled. + - `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. + - `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. + - `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. + - `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration. + - `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels. + - `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels. + - `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job. + - `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels. + - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. + - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`. + - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`. + - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`. + - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`. + - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. EOT type = object({ - multi_runner_config_v2 = optional(map(object({ - tags = optional(map(string), {}) + 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), {}) - additional_trust_policy_json = optional(string, null) - path = optional(string, null) - permissions_boundary = optional(string, null) - }), {}) - }) + roles = optional(object({ + path = optional(string, null) + permissions_boundary = optional(string, null) + }), {}) - github = optional(object({ - organization_runners = optional(bool, false) + runner = optional(object({ + os = optional(string, null) + architecture = optional(string, null) + 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 = optional(number, null) + 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), {}) + additional_trust_policy_json = optional(string, null) + path = optional(string, null) + permissions_boundary = optional(string, null) }), {}) + }), {}) - lambda = optional(object({ - tags = optional(map(string), {}) + github = optional(object({ + app = optional(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 + })) + }), null) + additional_apps = optional(list(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 })) + installation_id = optional(string) + installation_id_ssm = optional(object({ arn = string, name = string })) + })), []) + repository_white_list = optional(list(string), []) + }), {}) + + enterprise_server = optional(object({ + url = optional(string, null) + ssl_verify = optional(bool, true) + }), {}) + + user_agent = optional(string, "github-aws-runners") + + webhook = optional(object({ + queue_selection_strategy = optional(string, "first") + eventbridge = optional(object({ + enable = optional(bool, true) + accept_events = optional(list(string), []) }), {}) + matcher_config_parameter_store_tier = optional(string, "Standard") + }), {}) - 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) + lambda = optional(object({ + artifact = optional(object({ + s3 = optional(object({ + bucket = optional(string, null) }), {}) - redrive_build_queue = optional(object({ - enabled = bool - maxReceiveCount = number - }), { - enabled = false - maxReceiveCount = null - }) - tags = optional(map(string), {}) }), {}) - + scale = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + }), {}) + runtime = optional(string, "nodejs24.x") + architecture = optional(string, "arm64") + principals = optional(list(object({ + type = string + identifiers = list(string) + })), []) + 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) + }), {}) scale_up = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 30) reserved_concurrent_executions = optional(number, 1) job_queued_check_enabled = optional(bool, null) - tags = optional(map(string), {}) + event_source_mapping = optional(object({ + batch_size = optional(number, 10) + maximum_batching_window_in_seconds = optional(number, 0) + }), {}) + tags = optional(map(string), {}) }), {}) - scale_down = optional(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") })), []) + tags = optional(map(string), {}) + }), {}) + webhook = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + api_gateway_access_log_settings = optional(object({ + destination_arn = string + format = string + }), null) + memory_size = optional(number, 256) + timeout = optional(number, 10) + tags = optional(map(string), {}) }), {}) - pool = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 60) + reserved_concurrent_executions = optional(number, 1) config = optional(list(object({ schedule_expression = string schedule_expression_timezone = optional(string) size = number })), []) - runner_owner = optional(string, null) - tags = optional(map(string), {}) + include_busy_runners = optional(bool, false) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) + }), {}) + + queue = optional(object({ + delay_webhook_event = optional(number, 30) + job_queue_retention_in_seconds = optional(number, 86400) + visibility_timeout_seconds = optional(number, 180) + redrive_build_queue = optional(object({ + enabled = optional(bool, false) + maxReceiveCount = optional(number, null) + }), { + enabled = false + maxReceiveCount = null + }) + tags = optional(map(string), {}) + encryption = optional(object({ + kms_data_key_reuse_period_seconds = number + kms_master_key_id = string + sqs_managed_sse_enabled = bool + }), { + kms_data_key_reuse_period_seconds = null + kms_master_key_id = null + sqs_managed_sse_enabled = true + }) + }), {}) + + ssm = optional(object({ + paths = optional(object({ + root = optional(string, null) + app = optional(string, "app") + webhook = optional(string, "webhook") + tokens = optional(string, "runners/tokens") + config = optional(string, "runners/config") + }), {}) + kms_key_id = optional(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, null) + minimumDaysOld = optional(number, 1) + dryRun = optional(bool, false) + }), {}) + }), {}) + }), {}) + + observability = optional(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 = optional(bool, true) + enable_spot_termination_warning = optional(bool, true) + }), {}) + }), {}) + }), {}) + + compute_provider = optional(object({ + ec2 = optional(object({ + vpc_id = optional(string, null) + subnet_ids = optional(list(string), null) + managed_security_group_enabled = optional(bool, true) + 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 + }]) + additional_security_group_ids = optional(list(string), []) + cloudwatch_agent = optional(object({ + config = optional(string, null) + }), {}) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, false) + tags = optional(map(string), {}) + ami = optional(object({ + housekeeper = optional(object({ + enabled = optional(bool, false) + cleanup_config = optional(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) + }), {}) + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + timeout = optional(number, 300) + }), {}) + schedule = optional(object({ + expression = optional(string, "cron(11 7 * * ? *)") + }), {}) + }), {}) + }), {}) + instance_termination_watcher = optional(object({ + enabled = optional(bool, false) + features = optional(object({ + enable_spot_termination_handler = optional(bool, true) + enable_spot_termination_notification_watcher = optional(bool, true) + }), {}) + enable_runner_deregistration = optional(bool, true) + environment_variables = optional(map(string), {}) + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + lambda = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + }), {}) + }), {}) + runner_binaries = optional(object({ + enabled = optional(bool, true) + s3 = optional(object({ + encryption = optional(object({ + enabled = optional(bool, true) + bucket_key_enabled = optional(bool, null) + sse_algorithm = optional(string, "AES256") + kms_master_key_id = optional(string, null) + }), {}) + tags = optional(map(string), {}) + versioning = optional(string, "Disabled") + logging = optional(object({ + bucket = optional(string, null) + prefix = optional(string, null) + }), {}) + }), {}) + syncer = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + timeout = optional(number, 300) + }), {}) + schedule = optional(object({ + expression = optional(string, "cron(27 * * * ? *)") + state = optional(string, "ENABLED") + }), {}) + }), {}) + }), {}) + }), {}) + }), {}) + + multi_runner_config = optional(map(object({ + tags = optional(map(string), {}) + + runner = optional(object({ + os = optional(string, null) + architecture = optional(string, null) + boot_time_in_minutes = optional(number, null) + disable_default_labels = optional(bool, null) + extra_labels = optional(list(string), null) + group_name = optional(string, null) + name_prefix = optional(string, null) + run_as_root = optional(bool, null) + run_as = optional(string, null) + maximum_count = optional(number, null) + ephemeral = optional(bool, null) + jit_config_enabled = optional(bool, null) + auto_update_disabled = optional(bool, null) + tags = optional(map(string), {}) + hooks = optional(object({ + job_started = optional(string, null) + job_completed = optional(string, null) + }), {}) + iam = optional(object({ + role = optional(object({ + arn = string + }), null) + managed_policy_arns = optional(map(string), null) + additional_trust_policy_json = optional(string, null) + path = optional(string, null) + permissions_boundary = optional(string, null) + }), {}) + }), {}) + + github = optional(object({ + organization_runners = optional(bool, false) + }), {}) + + lambda = optional(object({ + runtime = optional(string, null) + architecture = optional(string, null) + subnet_ids = optional(list(string), null) + security_group_ids = optional(list(string), null) + tags = optional(map(string), {}) + role = optional(object({ + path = optional(string, null) + permissions_boundary = optional(string, null) + }), {}) + scale_up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) + }), {}) + + queue = optional(object({ + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) + redrive_build_queue = optional(object({ + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) + tags = optional(map(string), {}) }), {}) job_retry = optional(object({ @@ -247,21 +909,51 @@ variable "experimental" { }), {}) ssm = optional(object({ + paths = optional(object({ + root = optional(string, null) + tokens = optional(string, null) + config = optional(string, null) + }), {}) 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), {}) + schedule_expression = optional(string, null) + state = optional(string, null) + tags = optional(map(string), {}) + lambda = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + }), {}) + config = optional(object({ + tokenPath = optional(string, null) + minimumDaysOld = optional(number, null) + dryRun = optional(bool, null) + }), {}) }), {}) }), {}) observability = optional(object({ logs = optional(object({ - tags = optional(map(string), {}) + level = optional(string, null) + retention_in_days = optional(number, null) + kms_key_id = optional(string, null) + class = optional(string, null) + tags = optional(map(string), {}) + }), {}) + tracing = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, null) + capture_error = optional(bool, null) + }), {}) + metrics = optional(object({ + enable = optional(bool, null) + namespace = optional(string, null) + metric = optional(object({ + enable_github_app_rate_limit = optional(bool, null) + enable_job_retry = optional(bool, null) + }), {}) }), {}) }), {}) @@ -305,7 +997,7 @@ variable "experimental" { config = optional(string, null) }), {}) binaries_syncer = optional(object({ - enabled = optional(bool, true) + enabled = optional(bool, null) }), {}) detailed_monitoring_enabled = optional(bool, false) ssm_enabled = optional(bool, false) @@ -317,12 +1009,27 @@ variable "experimental" { 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_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), null) + managed_security_group_enabled = optional(bool, null) + 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 + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) instance_profile = optional(object({ name = string }), null) @@ -390,23 +1097,4 @@ variable "experimental" { })), {}) }) default = {} - - validation { - condition = alltrue([ - for runner_config in values(var.experimental.multi_runner_config_v2) : - 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 exactly one compute-provider block. Supported compute-provider blocks: ec2." - } - - validation { - condition = alltrue([ - for runner_config in values(var.experimental.multi_runner_config_v2) : - runner_config.runner.iam.role == null || length(runner_config.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/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 97f9785d45..d766bf90a0 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -75,7 +75,7 @@ variable "prefix" { } variable "kms_key_arn" { - description = "Optional CMK Key ARN to be used for Parameter Store." + description = "Stable/v1 optional KMS key ARN for Parameter Store. Experimental v2 uses `experimental.ssm.kms_key_id`; this flat value only seeds stable-mode translation." type = string default = null } @@ -411,7 +411,7 @@ variable "log_class" { } variable "lambda_s3_bucket" { - description = "S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly." + description = "Stable/v1 S3 bucket containing Lambda artifacts. A non-null value takes precedence over flat local-zip inputs during stable-mode translation. Experimental v2 uses the shared `experimental.lambda.artifact.s3.bucket`." type = string default = null } @@ -639,19 +639,19 @@ variable "runner_additional_security_group_ids" { } variable "runners_lambda_s3_key" { - description = "S3 key for runners lambda function. Required if using S3 bucket to specify lambdas." + description = "Stable/v1 S3 key for the scaling control-plane Lambda archive. Used when `lambda_s3_bucket` is set. Experimental v2 uses `experimental.lambda.scale.artifact.s3.key`." 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." + description = "Stable/v1 optional object version for the scaling control-plane Lambda archive. Experimental v2 uses `experimental.lambda.scale.artifact.s3.object_version`." type = string default = null } variable "runners_lambda_zip" { - description = "File location of the lambda zip file for scaling runners." + description = "Stable/v1 local scaling control-plane Lambda archive. A configured `lambda_s3_bucket` takes precedence. Experimental v2 uses `experimental.lambda.scale.artifact.zip`." type = string default = null } diff --git a/modules/multi-runner/versions.tf b/modules/multi-runner/versions.tf index f8a5674a77..7386c84b03 100644 --- a/modules/multi-runner/versions.tf +++ b/modules/multi-runner/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index 96850ba485..5b5b9e15fc 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -1,6 +1,6 @@ locals { runner_matcher_config = { - for k, v in local.multi_runner_config : k => { + for k, v in local.translated_experimental.multi_runner_config : k => { id = aws_sqs_queue.queued_builds[k].id arn = aws_sqs_queue.queued_builds[k].arn computeProvider = local.compute_provider_types[k] @@ -12,44 +12,44 @@ locals { module "webhook" { source = "../webhook" prefix = var.prefix - tags = local.tags - kms_key_arn = var.kms_key_arn - eventbridge = var.eventbridge + tags = merge(local.translated_experimental.tags, { "ghr:environment" = var.prefix }) + kms_key_arn = local.translated_experimental.ssm.kms_key_id + eventbridge = local.translated_experimental.webhook.eventbridge runner_matcher_config = local.runner_matcher_config - matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier + matcher_config_parameter_store_tier = local.translated_experimental.webhook.matcher_config_parameter_store_tier ssm_paths = { - root = local.ssm_root_path - webhook = var.ssm_paths.webhook + root = trimsuffix(coalesce(local.translated_experimental.ssm.paths.root, "/github-action-runners/${var.prefix}"), "/") + webhook = local.translated_experimental.ssm.paths.webhook } github_app_parameters = { webhook_secret = local.github_app_parameters.webhook_secret } - lambda_s3_bucket = var.lambda_s3_bucket - webhook_lambda_s3_key = var.webhook_lambda_s3_key - webhook_lambda_s3_object_version = var.webhook_lambda_s3_object_version - webhook_lambda_apigateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings - lambda_runtime = var.lambda_runtime - lambda_architecture = var.lambda_architecture - lambda_zip = var.webhook_lambda_zip - lambda_timeout = var.webhook_lambda_timeout - lambda_memory_size = var.webhook_lambda_memory_size - 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 + lambda_s3_bucket = local.translated_experimental.lambda.webhook.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + webhook_lambda_s3_key = try(local.translated_experimental.lambda.webhook.artifact.s3.key, null) + webhook_lambda_s3_object_version = try(local.translated_experimental.lambda.webhook.artifact.s3.object_version, null) + webhook_lambda_apigateway_access_log_settings = local.translated_experimental.lambda.webhook.api_gateway_access_log_settings + lambda_runtime = local.translated_experimental.lambda.runtime + lambda_architecture = local.translated_experimental.lambda.architecture + lambda_zip = local.translated_experimental.lambda.webhook.artifact.zip + lambda_timeout = local.translated_experimental.lambda.webhook.timeout + lambda_memory_size = local.translated_experimental.lambda.webhook.memory_size + lambda_tags = merge(local.translated_experimental.lambda.tags, local.translated_experimental.lambda.webhook.tags) + tracing_config = local.translated_experimental.observability.tracing + logging_retention_in_days = local.translated_experimental.observability.logs.retention_in_days + logging_kms_key_id = local.translated_experimental.observability.logs.kms_key_id + log_class = local.translated_experimental.observability.logs.class - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - repository_white_list = var.repository_white_list - queue_selection_strategy = var.queue_selection_strategy + role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) + role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) + repository_white_list = local.translated_experimental.github.repository_white_list + queue_selection_strategy = local.translated_experimental.webhook.queue_selection_strategy - lambda_subnet_ids = var.lambda_subnet_ids - lambda_security_group_ids = var.lambda_security_group_ids + lambda_subnet_ids = local.translated_experimental.lambda.subnet_ids + lambda_security_group_ids = local.translated_experimental.lambda.security_group_ids aws_partition = var.aws_partition - log_level = var.log_level + log_level = local.translated_experimental.observability.logs.level } diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index c46e458ed9..2363e5b8cc 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -2,11 +2,13 @@ > This module is treated as an internal module; breaking changes do not trigger a major release bump. -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. +This internal module implements the experimental provider-neutral runner control plane selected by `experimental.multi_runner_config`. 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 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`. Exactly one provider block must be populated, and its presence must be known during planning; there is no separate input discriminator. Before creating the common runner role, the stack calls the selected provider's isolated `trust-policy` module to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full compute-provider module, which receives the resolved runner role only after it is created. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common stack attaches each returned policy group to its runner, scale-up, scale-down, or pool role. The runner-stack output groups provider-specific resources under the matching dynamic provider key, which also identifies the selected provider. EC2 is the only implemented Terraform compute provider in this phase. +Provider-owned settings remain nested under a typed provider block. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. `multi-runner` resolves experimental globals and lane overrides first, then its final forwarding adapter preserves the wrapped `{ ec2 = ... }` object expected by this module. Exactly one provider block must be non-null, and the stack derives its provider type from that block rather than from a separate discriminator. + +The EC2 block reaches runner-stack with `compute_provider.ec2.binaries_syncer = { enabled, s3 }`; the S3 object is null when synchronization is disabled. Binary discovery and this shape adaptation happen in `multi-runner`, not inside runner-stack. Before creating the common runner role, the stack calls the selected provider's isolated `trust-policy` module to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full compute-provider module, which receives the resolved runner role only after it is created. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common stack attaches each returned policy group to its runner, scale-up, scale-down, or pool role. Provider-specific outputs remain grouped under the matching provider key, such as `provider.ec2`. EC2 is the only implemented Terraform compute provider in this phase. ## Tagging @@ -14,7 +16,7 @@ Provider-owned settings are typed and nested under the selected provider block; Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `scale_up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `scale_up.tags`. -Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. +Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and lane `compute_provider.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. ## Overview @@ -63,20 +65,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 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.59.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | @@ -87,7 +89,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,13 +102,13 @@ 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.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`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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 | +| [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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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), {})
principals = optional(list(object({
type = string
identifiers = list(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 | @@ -114,13 +116,13 @@ yarn run dist | [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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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), {})
additional_trust_policy_json = optional(string, null)
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 | +| [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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; IAM policy shape remains static. It does not select encryption for runtime-created runner parameters.
- `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_id = optional(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 | -|------|-------------| +| ---- | ----------- | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | | [runner](#output\_runner) | Common runner resources. The role is null when an external runner role is used. | diff --git a/modules/runner-stack/common-config.tf b/modules/runner-stack/common-config.tf index bbbe12b65f..125e738fc9 100644 --- a/modules/runner-stack/common-config.tf +++ b/modules/runner-stack/common-config.tf @@ -33,7 +33,7 @@ locals { 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 = var.ssm.kms_key + kms_key_id = var.ssm.kms_key_id 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}" diff --git a/modules/runner-stack/job-retry.tf b/modules/runner-stack/job-retry.tf index 2631f1c279..33cedda00c 100644 --- a/modules/runner-stack/job-retry.tf +++ b/modules/runner-stack/job-retry.tf @@ -28,7 +28,7 @@ module "job_retry" { role = { path = local.lambda_role_path permissions_boundary = var.lambda.role.permissions_boundary - principals = [] + principals = var.lambda.principals } } runner = { @@ -48,7 +48,7 @@ module "job_retry" { } } ssm = { - kms_key = local.kms_key + kms_key_id = local.kms_key_id } observability = var.observability tags = { diff --git a/modules/runner-stack/job-retry/README.md b/modules/runner-stack/job-retry/README.md index d8f40a4d51..981c330e5e 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,15 +11,15 @@ 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.60.0 | ## 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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(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 | +| ---- | ----------- | ---- | ------- | :------: | +| [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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `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)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(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_id = 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
})
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/job-retry/iam-policies.tf b/modules/runner-stack/job-retry/iam-policies.tf index 0979c1532c..e1778f9035 100644 --- a/modules/runner-stack/job-retry/iam-policies.tf +++ b/modules/runner-stack/job-retry/iam-policies.tf @@ -87,19 +87,18 @@ data "aws_iam_policy_document" "job_retry" { resources = [var.config.queue.build.arn] } - dynamic "statement" { - for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] - - content { - effect = "Allow" + statement { + effect = "Allow" - actions = [ - "kms:Encrypt", - "kms:Decrypt", - "kms:GenerateDataKey", - ] + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + ] - resources = [statement.value.arn] - } + resources = [coalesce( + var.config.ssm.kms_key_id, + "arn:${var.config.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", + )] } } diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/runner-stack/job-retry/job-retry.tf index 1530bafb20..a21ab09342 100644 --- a/modules/runner-stack/job-retry/job-retry.tf +++ b/modules/runner-stack/job-retry/job-retry.tf @@ -23,6 +23,7 @@ locals { 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 + NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 USER_AGENT = var.config.github.user_agent JOB_QUEUE_SCALE_UP_URL = var.config.queue.build.url PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github.app_parameters.id : p.name]) 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 88882d1e43..a332541418 100644 --- a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl +++ b/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl @@ -51,9 +51,10 @@ variables { github = { organization_runners = false enterprise_server = { - url = "" + url = "https://experimental-job-retry.example.com" + ssl_verify = false } - user_agent = "job-retry-test" + user_agent = "experimental-job-retry-user-agent" app_parameters = { key_base64 = [ { @@ -98,9 +99,7 @@ variables { } } ssm = { - kms_key = { - arn = "arn:aws:kms:eu-west-1:123456789012:key/job-retry-test" - } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/job-retry-test" } observability = { logs = { @@ -147,14 +146,17 @@ run "preserves_nested_job_retry_configuration" { assert { condition = ( - output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" + output.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-job-retry.example.com" + && output.lambda.function.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && output.lambda.function.environment[0].variables["USER_AGENT"] == "experimental-job-retry-user-agent" + && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64:/github-runner/key-base64-2" && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/github-runner/installation-id-2" && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2") && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2") && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2") ) - error_message = "Job retry must pass every GitHub App parameter and grant access to every corresponding SSM ARN." + error_message = "Job retry must receive the nested GitHub connection settings, pass every app parameter, and grant access to every corresponding SSM ARN." } assert { @@ -182,6 +184,7 @@ run "preserves_nested_job_retry_configuration" { condition = ( output.lambda.log_group.log_group_class == "INFREQUENT_ACCESS" && length(data.aws_iam_policy_document.job_retry.statement) == 4 + && data.aws_iam_policy_document.job_retry.statement[3].resources == toset(["arn:aws:kms:eu-west-1:123456789012:key/job-retry-test"]) && 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 @@ -286,7 +289,8 @@ run "does_not_enable_partial_vpc_configuration" { condition = ( length(aws_lambda_function.job_retry.vpc_config) == 0 && length(aws_iam_role_policy_attachment.job_retry_vpc_execution_role) == 0 + && data.aws_iam_policy_document.job_retry.statement[3].resources == toset(["arn:aws:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000"]) ) - error_message = "The VPC block and managed policy must both remain disabled until subnet and security-group lists are complete." + error_message = "Partial VPC inputs must stay disabled while a null KMS key keeps the static IAM statement on its inert sentinel ARN." } } diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/runner-stack/job-retry/variables.tf index 6fd17d05bb..69bf67f794 100644 --- a/modules/runner-stack/job-retry/variables.tf +++ b/modules/runner-stack/job-retry/variables.tf @@ -22,6 +22,7 @@ variable "config" { - `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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests. - `github.user_agent`: Optional User-Agent sent to GitHub. - `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. - `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. @@ -30,7 +31,7 @@ variable "config" { - `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. + - `ssm.kms_key_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply. - `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. @@ -78,7 +79,8 @@ variable "config" { github = object({ organization_runners = bool enterprise_server = object({ - url = optional(string, null) + url = optional(string, null) + ssl_verify = optional(bool, true) }) user_agent = optional(string, null) app_parameters = object({ @@ -103,9 +105,7 @@ variable "config" { }) }) ssm = object({ - kms_key = optional(object({ - arn = string - }), null) + kms_key_id = optional(string, null) }) observability = object({ logs = object({ diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf index 0e075b1378..4e414804e2 100644 --- a/modules/runner-stack/pool.tf +++ b/modules/runner-stack/pool.tf @@ -12,7 +12,7 @@ module "pool" { user_agent = var.github.user_agent github_app_parameters = var.github.app_parameters runners_maximum_count = var.runner.maximum_count - kms_key = local.kms_key + kms_key_id = local.kms_key_id lambda = { log_level = var.observability.logs.level logging_retention_in_days = var.observability.logs.retention_in_days @@ -30,6 +30,7 @@ module "pool" { timeout = var.pool.lambda.timeout zip = local.lambda_zip parameter_store_tags = local.parameter_store_tags + principals = var.lambda.principals } pool = var.pool.config include_busy_runners = var.pool.include_busy_runners diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md index 98e5e3fadc..c2d96a7eba 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,15 +50,15 @@ 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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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 | +| [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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters. The ARN may be unknown until apply; IAM policy shape remains static.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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_id = optional(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 | -|------|-------------| +| ---- | ----------- | | [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/runner-stack/pool/iam-policies.tf b/modules/runner-stack/pool/iam-policies.tf index d0a3cb0b0b..840c5360b8 100644 --- a/modules/runner-stack/pool/iam-policies.tf +++ b/modules/runner-stack/pool/iam-policies.tf @@ -41,15 +41,13 @@ data "aws_iam_policy_document" "pool_common" { ) } - dynamic "statement" { - for_each = var.config.kms_key == null ? [] : [var.config.kms_key] - - content { - effect = "Allow" - - actions = ["kms:Decrypt"] - resources = [statement.value.arn] - } + statement { + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [coalesce( + var.config.kms_key_id, + "arn:${var.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", + )] } } diff --git a/modules/runner-stack/pool/pool.tf b/modules/runner-stack/pool/pool.tf index e44424f179..d02d058431 100644 --- a/modules/runner-stack/pool/pool.tf +++ b/modules/runner-stack/pool/pool.tf @@ -121,6 +121,15 @@ data "aws_iam_policy_document" "lambda_assume_role_policy" { type = "Service" identifiers = ["lambda.amazonaws.com"] } + + dynamic "principals" { + for_each = var.config.lambda.principals + + content { + type = principals.value.type + identifiers = principals.value.identifiers + } + } } } diff --git a/modules/runner-stack/pool/tests/provider.tftest.hcl b/modules/runner-stack/pool/tests/provider.tftest.hcl index 192dd82ad8..bfbd59091d 100644 --- a/modules/runner-stack/pool/tests/provider.tftest.hcl +++ b/modules/runner-stack/pool/tests/provider.tftest.hcl @@ -25,6 +25,10 @@ variables { zip = "runners.zip" subnet_ids = [] parameter_store_tags = "{}" + principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:role/local-testing"] + }] } tags = { Environment = "pool-test" @@ -78,11 +82,9 @@ variables { schedule_expression_timezone = "UTC" size = 2 }] - include_busy_runners = false - role_permissions_boundary = null - kms_key = { - arn = "arn:aws:kms:eu-west-1:123456789012:key/pool-test" - } + include_busy_runners = false + role_permissions_boundary = null + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/pool-test" role_path = "/" ssm_token_path = "/github-runner/tokens" ssm_config_path = "/github-runner/config" @@ -112,6 +114,14 @@ variables { run "provider_supplies_only_compute_specific_pool_configuration" { command = plan + assert { + condition = ( + length(data.aws_iam_policy_document.lambda_assume_role_policy.statement[0].principals) == 2 && + contains(data.aws_iam_policy_document.lambda_assume_role_policy.statement[0].principals[*].type, "AWS") + ) + error_message = "The pool Lambda trust policy must include configured additional principals." + } + assert { condition = toset(keys(output.pool)) == toset(["lambda", "log_group", "role"]) error_message = "The pool module must expose its resources through one nested output." @@ -155,8 +165,11 @@ run "provider_supplies_only_compute_specific_pool_configuration" { } 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." + condition = ( + length(data.aws_iam_policy_document.pool_common.statement) == 4 + && data.aws_iam_policy_document.pool_common.statement[3].resources == toset(["arn:aws:kms:eu-west-1:123456789012:key/pool-test"]) + ) + error_message = "The pool KMS policy statement must consume the scalar key ARN." } assert { diff --git a/modules/runner-stack/pool/variables.tf b/modules/runner-stack/pool/variables.tf index 0956209a39..f0cbfb556b 100644 --- a/modules/runner-stack/pool/variables.tf +++ b/modules/runner-stack/pool/variables.tf @@ -19,6 +19,7 @@ variable "config" { - `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. + - `lambda.principals`: Additional principals allowed to assume the pool Lambda role. - `tags`: Common tags added to pool resources. - `ghes`: GitHub Enterprise Server connection configuration. - `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub. @@ -43,8 +44,7 @@ variable "config" { - `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. + - `kms_key_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters. The ARN may be unknown until apply; IAM policy shape remains static. - `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. @@ -71,6 +71,10 @@ variable "config" { zip = string subnet_ids = list(string) parameter_store_tags = string + principals = optional(list(object({ + type = string + identifiers = list(string) + })), []) }) tags = map(string) ghes = object({ @@ -98,11 +102,9 @@ variable "config" { schedule_expression_timezone = string size = number })) - include_busy_runners = bool - role_permissions_boundary = string - kms_key = optional(object({ - arn = string - }), null) + include_busy_runners = bool + role_permissions_boundary = string + kms_key_id = optional(string, null) role_path = string ssm_token_path = string ssm_config_path = string diff --git a/modules/runner-stack/scale-runners.tf b/modules/runner-stack/scale-runners.tf index ac5ca69845..3431274089 100644 --- a/modules/runner-stack/scale-runners.tf +++ b/modules/runner-stack/scale-runners.tf @@ -19,6 +19,7 @@ module "scale_runners" { role = { path = local.lambda_role_path permissions_boundary = var.lambda.role.permissions_boundary + principals = var.lambda.principals } } runner = var.runner @@ -31,7 +32,7 @@ module "scale_runners" { 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 + kms_key_id = local.kms_key_id parameter_store_tags = local.parameter_store_tags } observability = var.observability diff --git a/modules/runner-stack/scale-runners/README.md b/modules/runner-stack/scale-runners/README.md index d3113fb1d3..f4b92c7127 100644 --- a/modules/runner-stack/scale-runners/README.md +++ b/modules/runner-stack/scale-runners/README.md @@ -10,15 +10,15 @@ 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 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.59.0 | ## 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,15 +63,15 @@ 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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(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 | +| [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.
- `lambda.role.principals`: Additional principals allowed to assume 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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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 = list(map(string))
id = list(map(string))
installation_id = list(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_id = 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
})
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/lambda-iam-policies.tf b/modules/runner-stack/scale-runners/lambda-iam-policies.tf index 05922c734e..8568a0f3bc 100644 --- a/modules/runner-stack/scale-runners/lambda-iam-policies.tf +++ b/modules/runner-stack/scale-runners/lambda-iam-policies.tf @@ -6,6 +6,15 @@ data "aws_iam_policy_document" "lambda_assume_role" { type = "Service" identifiers = ["lambda.amazonaws.com"] } + + dynamic "principals" { + for_each = var.config.lambda.role.principals + + content { + type = principals.value.type + identifiers = principals.value.identifiers + } + } } } diff --git a/modules/runner-stack/scale-runners/scale-down-iam-policies.tf b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf index c5f1787b52..19bb40cdc3 100644 --- a/modules/runner-stack/scale-runners/scale-down-iam-policies.tf +++ b/modules/runner-stack/scale-runners/scale-down-iam-policies.tf @@ -12,14 +12,13 @@ data "aws_iam_policy_document" "scale_down_common" { ) } - dynamic "statement" { - for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] - - content { - effect = "Allow" - actions = ["kms:Decrypt"] - resources = [statement.value.arn] - } + statement { + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [coalesce( + var.config.ssm.kms_key_id, + "arn:${var.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", + )] } } diff --git a/modules/runner-stack/scale-runners/scale-up-iam-policies.tf b/modules/runner-stack/scale-runners/scale-up-iam-policies.tf index 1e32597427..8aa4d8674f 100644 --- a/modules/runner-stack/scale-runners/scale-up-iam-policies.tf +++ b/modules/runner-stack/scale-runners/scale-up-iam-policies.tf @@ -32,14 +32,13 @@ data "aws_iam_policy_document" "scale_up_common" { resources = [var.config.queue.build.arn] } - dynamic "statement" { - for_each = var.config.ssm.kms_key == null ? [] : [var.config.ssm.kms_key] - - content { - effect = "Allow" - actions = ["kms:Decrypt"] - resources = [statement.value.arn] - } + statement { + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [coalesce( + var.config.ssm.kms_key_id, + "arn:${var.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", + )] } } diff --git a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl index 0f383c3b66..b8bf32c569 100644 --- a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl @@ -35,6 +35,10 @@ variables { role = { path = "/scale-runners-test/" permissions_boundary = "arn:aws-us-gov:iam::123456789012:policy/permissions-boundary" + principals = [{ + type = "AWS" + identifiers = ["arn:aws-us-gov:iam::123456789012:role/local-testing"] + }] } } runner = { @@ -101,9 +105,7 @@ variables { Key = "Environment" Value = "test" }]) - kms_key = { - arn = "arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test" - } + kms_key_id = "arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test" } observability = { logs = { @@ -211,6 +213,14 @@ variables { run "assembles_provider_neutral_scaling_control_plane" { command = plan + assert { + condition = ( + length(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals) == 2 && + contains(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals[*].type, "AWS") + ) + error_message = "The scaling Lambda trust policy must include configured additional principals." + } + assert { condition = ( toset(keys(output.scale_up)) == toset(["lambda", "log_group", "role"]) @@ -337,6 +347,8 @@ run "assembles_provider_neutral_scaling_control_plane" { && 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 + && data.aws_iam_policy_document.scale_up_common.statement[3].resources == toset(["arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test"]) + && data.aws_iam_policy_document.scale_down_common.statement[1].resources == toset(["arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test"]) && 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 index fd1f015595..2fc3af5ff2 100644 --- a/modules/runner-stack/scale-runners/variables.tf +++ b/modules/runner-stack/scale-runners/variables.tf @@ -19,6 +19,7 @@ variable "config" { - `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. + - `lambda.role.principals`: Additional principals allowed to assume 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. @@ -40,7 +41,7 @@ variable "config" { - `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.kms_key_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply. - `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. @@ -81,6 +82,10 @@ variable "config" { role = object({ path = string permissions_boundary = optional(string, null) + principals = optional(list(object({ + type = string + identifiers = list(string) + })), []) }) }) runner = object({ @@ -120,9 +125,7 @@ variable "config" { config_path = string config_path_arn = string parameter_store_tags = string - kms_key = optional(object({ - arn = string - }), null) + kms_key_id = optional(string, null) }) observability = object({ logs = object({ diff --git a/modules/runner-stack/ssm-housekeeper.tf b/modules/runner-stack/ssm-housekeeper.tf index 18912392d4..0e4a78cd5d 100644 --- a/modules/runner-stack/ssm-housekeeper.tf +++ b/modules/runner-stack/ssm-housekeeper.tf @@ -37,6 +37,7 @@ module "ssm_housekeeper" { role = { path = local.lambda_role_path permissions_boundary = var.lambda.role.permissions_boundary + principals = var.lambda.principals } } observability = { diff --git a/modules/runner-stack/ssm-housekeeper/README.md b/modules/runner-stack/ssm-housekeeper/README.md index 4cc0af9d63..863e639ec3 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 | +| ---- | ----------- | ---- | ------- | :------: | +| [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.
- `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda 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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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 index 8599e378f6..e093301eac 100644 --- a/modules/runner-stack/ssm-housekeeper/iam-policies.tf +++ b/modules/runner-stack/ssm-housekeeper/iam-policies.tf @@ -6,6 +6,15 @@ data "aws_iam_policy_document" "lambda_assume_role" { type = "Service" identifiers = ["lambda.amazonaws.com"] } + + dynamic "principals" { + for_each = var.config.lambda.role.principals + + content { + type = principals.value.type + identifiers = principals.value.identifiers + } + } } } diff --git a/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl b/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl index 38c04e14c5..134417cecc 100644 --- a/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl +++ b/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl @@ -64,6 +64,10 @@ variables { role = { path = "/runner-stack/" permissions_boundary = null + principals = [{ + type = "AWS" + identifiers = ["arn:aws-us-gov:iam::123456789012:role/local-testing"] + }] } } observability = { @@ -98,6 +102,14 @@ variables { run "configures_schedule_cleanup_and_outputs" { command = plan + assert { + condition = ( + length(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals) == 2 && + contains(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals[*].type, "AWS") + ) + error_message = "The housekeeper Lambda trust policy must include configured additional principals." + } + assert { condition = ( aws_cloudwatch_event_rule.ssm_housekeeper.schedule_expression == "rate(6 hours)" && diff --git a/modules/runner-stack/ssm-housekeeper/variables.tf b/modules/runner-stack/ssm-housekeeper/variables.tf index 792b7d75bb..c54620369c 100644 --- a/modules/runner-stack/ssm-housekeeper/variables.tf +++ b/modules/runner-stack/ssm-housekeeper/variables.tf @@ -22,6 +22,7 @@ variable "config" { - `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. + - `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda 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. @@ -62,6 +63,10 @@ variable "config" { role = object({ path = string permissions_boundary = optional(string, null) + principals = optional(list(object({ + type = string + identifiers = list(string) + })), []) }) }) observability = object({ 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 94b6ee2c5d..a00a8ba7c8 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 @@ -92,9 +92,7 @@ module "external_iam" { } ssm = { - kms_key = { - arn = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" - } + kms_key_id = "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/variables.tf b/modules/runner-stack/variables.tf index fc1d46e21b..d03b9ee21d 100644 --- a/modules/runner-stack/variables.tf +++ b/modules/runner-stack/variables.tf @@ -181,6 +181,7 @@ variable "lambda" { - `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. + - `principals`: Additional principals allowed to assume the control-plane Lambda roles. - `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 @@ -196,6 +197,10 @@ variable "lambda" { subnet_ids = optional(list(string), []) security_group_ids = optional(list(string), []) tags = optional(map(string), {}) + principals = optional(list(object({ + type = string + identifiers = list(string) + })), []) role = optional(object({ path = optional(string, null) permissions_boundary = optional(string, null) @@ -333,8 +338,7 @@ variable "ssm" { - `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. + - `kms_key_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; IAM policy shape remains static. It does not select encryption for runtime-created runner parameters. - `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. @@ -352,10 +356,8 @@ variable "ssm" { tokens = string config = string }) - kms_key = optional(object({ - arn = string - }), null) - tags = optional(map(string), {}) + kms_key_id = optional(string, null) + tags = optional(map(string), {}) parameters = optional(object({ tags = optional(map(string), {}) }), {}) From d354272c0aca889dbda7f087cf7aff93d41a833c Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 03:48:26 +0200 Subject: [PATCH 14/49] docs(multi-runner): document experimental configuration --- docs/index.md | 18 +- .../internal/compute-provider-refactor.md | 432 ++++++++++++++++-- 2 files changed, 400 insertions(+), 50 deletions(-) diff --git a/docs/index.md b/docs/index.md index ae2713ff48..6e1101e9c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,23 @@ 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`. 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 exposes both contracts but requires callers to populate only one runner configuration map per module instance; 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 top-level `multi_runner_config` entries continue to use the unchanged `runners` module when `experimental.multi_runner_config` is empty. A non-empty experimental map takes priority over the stable map; the maps are not combined. Experimental entries use the new provider-oriented `runner-stack`. + +Multi-runner centralizes mode selection and canonical configuration in `config.experimental.translation.tf`. A non-empty experimental lane map selects v2; otherwise the file projects the flat globals and stable lanes into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base` by applying schema defaults, global/lane precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults. Provider selection and the shared runner-binary syncer and discovery use this plan-known base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-stack GitHub client settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume that final canonical representation. Stable lanes are adapted back into the existing `module.runners["configuration"]` call, preserving its Terraform addresses while removing a second configuration path. The `module.runner_stacks` call directly iterates the gated final lanes, inlines the environment tag and live GitHub App and build-queue references, exposes the scale-up, scale-down, and pool aliases expected by runner-stack, and forwards the remaining canonical objects, including the typed `compute_provider = { ec2 = ... }` wrapper. + +The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `enterprise_server`, `user_agent`, `webhook`, `lambda` (including nested `scale_up`, `scale_down`, `webhook`, and `pool` settings), `queue`, `ssm`, `observability`, and `compute_provider`. These globals configure v2 runner stacks and the applicable singleton shared components. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides remain lane-only. A nullable lane field with a corresponding experimental global inherits that global value when omitted or null. A lane that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. + +Global `experimental.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Lane `experimental.multi_runner_config[].queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures only the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues, and its CMK is independent from `experimental.ssm.kms_key_id`. The current v2 webhook, scale-up, and job-retry IAM policies do not derive KMS grants from a distinct queue CMK, so callers must grant those roles the required key permissions. For v2, `experimental.multi_runner_config[].queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].lambda.scale_up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. + +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner stacks consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.enterprise_server.url` defaults to `null` and configures v2 runner-stack GitHub clients and the termination watcher. `experimental.enterprise_server.ssl_verify` and `experimental.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. + +The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Runner-stack control-plane artifact selection is global under `experimental.lambda.scale.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. Root `experimental.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. + +`experimental.compute_provider.ec2.runner_binaries.enabled` defaults each EC2 lane to the shared synchronized distribution, while a nullable lane `compute_provider.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. + +Global `ssm.paths.root` is the base for shared and lane-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the lane key only for lane-owned paths. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every runner stack; it does not select encryption for runtime-created lane runner parameters. IAM consumers keep a static policy shape with a harmless sentinel resource when the value is null, so a real ARN may remain unknown until apply. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than lane overrides. + +Each lane still selects exactly one provider and supplies provider-specific required fields with its own `compute_provider` block. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider object reaches runner-stack, which also validates that exactly one typed block is populated before dispatch. The 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. Later releases will route the existing v1 translation through runner-stack, 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 7f20e56e0c..4d77613239 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. Its schema can change before it becomes stable. To enable it for the whole module instance, leave `multi_runner_config` empty and populate `experimental.multi_runner_config_v2`. When the v2 map is empty, existing `multi_runner_config` deployments continue to use the unchanged legacy implementation. Populating both maps is unsupported. + The provider-oriented Terraform interface is experimental. Its schema can change before it becomes stable. A non-empty `experimental.multi_runner_config` enables it for the whole module instance and takes priority over the stable top-level `multi_runner_config`; the two maps are not combined. When the experimental map is empty, existing stable `multi_runner_config` deployments continue to use the unchanged legacy implementation. ## Why this refactor exists @@ -16,8 +16,8 @@ The implementation is split into orchestration, provider-neutral control-plane c | Layer | Owns | | --- | --- | -| `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. | +| `multi-runner` | Module-level v1/v2 mode selection, flat/nested input projection, canonical global/lane resolution, direct runner-stack input adaptation, configuration keys, build queues, webhook matching, and runner-binary discovery. | +| `runner-stack` | Typed 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. | @@ -27,37 +27,56 @@ The implementation is split into orchestration, provider-neutral control-plane c The EC2 provider 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 in this phase. -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. +The modules below `runner-stack` are internal implementation boundaries, not standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config`; `multi-runner` calls `runner-stack`, which composes the internal modules. Their direct input and output contracts may change while v2 remains experimental. -`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. Native input validation enforces this common selection rule, while each compute-provider module owns its provider-specific semantic validation. The stack passes `compute_provider.` to the selected provider 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. +Each external v2 lane populates exactly one typed provider block, such as `experimental.multi_runner_config..compute_provider.ec2`; that block's presence must be known during planning because it determines routing. Multi-runner module validation enforces this selection rule through resource preconditions, while each compute-provider implementation owns its provider-specific semantic validation. -The common stack creates or selects the runner IAM role, but the selected provider owns the role's default trust-policy document. Each provider implements a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. The common stack uses the isolated trust-policy output when it creates the runner role and attaches the full provider's permission documents to the roles owned by the corresponding common components. A provider never creates or attaches a common IAM role. +After resolving global `experimental.compute_provider.ec2` values with the selected lane's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-stack`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-stack validates that exactly one provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It also passes the provider-neutral `runner`, `github`, `ssm`, and `observability` objects without expanding them back into prefixed scalar inputs. + +Binary discovery is completed before the stack call. `config.experimental.translation.tf` enriches the final canonical lane at `compute_provider.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_stacks` call then passes that lane's wrapped `compute_provider` object unchanged. Runner-stack and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.binaries_syncer`. + +The common stack creates or selects the runner IAM role, but the current EC2 provider owns the role's default trust-policy document. Each provider implementation supplies a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. The common stack uses the isolated trust-policy output when it creates the runner role and attaches the full provider's 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 rendered by an isolated provider submodule: -1. `runner-stack` selects the provider from the populated typed block. -2. `compute-providers//trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input. -3. `runner-stack` creates or selects the common runner role from the returned `assume_role_policy`. -4. The full compute provider receives the resolved role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements. -5. The provider returns its nested policy, environment-variable, and resource contract. -6. The common components attach the returned policies to the runner, scale-up, scale-down, and pool roles they own. +1. `multi-runner` validates the lane's typed provider selection, resolves its global and lane values, and invokes `runner-stack` with the wrapped provider configuration. +2. `runner-stack` derives the selected provider from the single non-null typed block. +3. `compute-providers//trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input. +4. `runner-stack` creates or selects the common runner role from the returned `assume_role_policy`. +5. The full compute provider receives the resolved role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements. +6. The provider returns its nested policy, environment-variable, and resource contract. +7. The common components attach the returned policies to the runner, scale-up, scale-down, and pool roles they own. The trust-policy output depends only on its input documents, not on the full provider resources that consume the runner role. This preserves provider ownership of the trust relationship while keeping the dependency graph one-way. +## Selection and canonical translation + +Multi-runner produces one canonical consumer representation for both input modes: + +1. `config.experimental.translation.tf` selects the module mode and builds `local.raw_translated_experimental`. A non-empty experimental lane map selects the nested `var.experimental` input for v2; otherwise the file projects flat module globals and stable `multi_runner_config` lanes into the same schema for v1. +2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/lane precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, queues, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. +3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and each enabled EC2 lane's `compute_provider.ec2.binaries_syncer.s3`. The remaining shared components, queues, and runner implementations consume this final canonical object. + +Stable lanes remain on `module.runners["configuration"]`: `runners.tf` adapts each final canonical lane back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate configuration source. This is not the phase-2 implementation migration to `runner-stack`. For v2, `module.runner_stacks` directly iterates the gated final lane map. Its input arguments inline the environment tag and live GitHub App and build-queue references, expose `scale_up`, `scale_down`, and `pool` as the sibling aliases expected by runner-stack, and forward the remaining canonical lane objects. Binary output enrichment and all other derived lane shaping are already complete in canonical translation. + ## Phase 1 dispatch and compatibility -Phase 1 exposes both contracts but requires callers to populate only one runner configuration map per module instance. An empty `experimental.multi_runner_config_v2` selects the stable v1 path. To select the experimental v2 path, `multi_runner_config` must be empty and the v2 map must be populated. The maps are never merged, and supplying both is unsupported. +Phase 1 exposes both contracts with deterministic module-level precedence. An empty `experimental.multi_runner_config` selects the stable v1 path. A non-empty experimental map selects the v2 path and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. ```mermaid flowchart TD - 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, with v1 empty| V2["Select v2"] - V1 --> Shared["Queues, webhook matching, binary discovery"] - V2 --> Shared - V1 --> Legacy["module.runners[configuration]"] - V2 --> Stack["module.runner_stacks[configuration]"] + Stable["top-level multi_runner_config and flat globals"] --> Select{"Is experimental.multi_runner_config non-empty?"} + Experimental["var.experimental"] --> Select + Select -->|No| V1["raw_translated_experimental: project flat v1"] + Select -->|Yes| V2["raw_translated_experimental: select nested v2"] + V1 --> Base["translated_experimental_base: defaults and global/lane resolution"] + V2 --> Base + Base --> Discovery["Provider selection, runner-binary syncer, and discovery"] + Discovery --> Final["translated_experimental: enrich EC2 binaries_syncer.s3"] + Final --> Singleton["Shared SSM, webhook, termination watcher, and AMI housekeeper"] + Final --> Shared["Build queues and webhook matching"] + Final -->|v1 legacy-argument adapter| Legacy["module.runners[configuration]"] + Final -->|v2 direct module input adaptation| Stack["module.runner_stacks[configuration]"] Stack --> Scaling["runner-stack/scale-runners"] Stack --> Pool["runner-stack/pool"] Stack --> Retry["runner-stack/job-retry"] @@ -70,36 +89,331 @@ flowchart TD Provider --> Pool ``` -The selected input is normalized once so shared resources can consume one representation. Stable normalization does not change stable runner dispatch: +The canonical object gives shared singleton resources one global representation and queues and runner implementations one fully resolved lane representation: -- 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. +- When `experimental.multi_runner_config` is empty, every key in the stable top-level `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address. +- Flat v1 inputs are projected into `raw_translated_experimental`, resolved into `translated_experimental_base`, finalized as `translated_experimental`, and then adapted by `runners.tf` to the existing child-module arguments. +- The v1 translation uses `runners_scale_up_lambda_timeout` for build-queue visibility, preserving the stable flat behavior. - Stable queue tagging and the flat `runners_map` output remain unchanged. -- When `multi_runner_config` is empty and `experimental.multi_runner_config_v2` is non-empty, every key in the v2 map calls `modules/runner-stack` at `module.runner_stacks["configuration"]`. +- When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-stack` at `module.runner_stacks["configuration"]`; stable-map entries are not dispatched. - Experimental resources are exposed separately through the nested `runners_map_v2` output. -- The maps are not combined, and there is no precedence rule between them. Populating both maps is unsupported. +- The maps are not combined. A non-empty v2 map has explicit priority over the stable map. 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 -Set the complete runner configuration map inside the nested experimental object to use the provider-oriented stack: +Nested global settings are the source of defaults for v2 runner stacks and the applicable singleton shared components. The GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume translated globals. Migrated v2 consumers do not fall back to matching flat inputs; those values seed the stable-mode translation only. The singleton-specific webhook, binary-syncer, termination-watcher, and AMI-housekeeper settings all have nested owners. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Per-lane values override globals only inside that lane and never configure singleton resources. Nested defaults mirror established v1 behavior, while nullable lane fields inherit the corresponding global when omitted or set to null. Set a global override only when the value is genuinely shared by every lane, and put lane-specific differences in the lane itself. An external lane `runner.iam.role` is the exception to normal inheritance: inherited managed policies and additional trust policy JSON are suppressed because the module does not manage that role. ```hcl module "multi_runner" { source = "github-aws-runners/github-runner/aws//modules/multi-runner" - # A non-empty v2 map is the module-level experimental opt-in. Leave - # multi_runner_config empty when using it. experimental = { - multi_runner_config_v2 = { + # Base tags for v2 queues and runner stacks and for translated singleton + # resources such as shared SSM, webhook, binary syncer, watcher, and AMI + # housekeeper. + tags = { + Workload = "runner-lanes" + ManagedBy = "terraform" + } + + roles = { + path = "/github-actions/" + } + + runner = { + os = "linux" + architecture = "arm64" + maximum_count = 4 + ephemeral = true + } + + github = { + # Required in v2. These nested values are authoritative for shared SSM + # and every v2 runner stack. + app = var.github_app + additional_apps = var.additional_github_apps + repository_white_list = [ + "example/example-repository", + ] + } + + # The URL also configures the shared termination watcher. TLS verification + # and the User-Agent remain runner-stack GitHub-client settings. + enterprise_server = { + url = var.ghes_url + ssl_verify = true + } + user_agent = "github-aws-runners" + + # Shared-webhook routing and matcher storage are global-only. + webhook = { + queue_selection_strategy = "first" + eventbridge = { + enable = true + accept_events = [] + } + matcher_config_parameter_store_tier = "Standard" + } + + lambda = { + runtime = "nodejs24.x" + architecture = "arm64" + artifact = { + s3 = { + # Shared bucket only; every component selects its own object. + bucket = var.lambda_artifact_bucket + } + } + scale = { + artifact = { + # Use zip instead for a local archive. Leave both fields null for the + # packaged runner archive. + zip = null + s3 = { + key = "runner-stack.zip" + object_version = null + } + } + } + principals = var.additional_lambda_principals + + # Singleton S3 wrappers select an object from the shared bucket. Merely + # setting lambda.artifact.s3.bucket does not switch a singleton from its + # packaged archive. + webhook = { + artifact = { + zip = null + s3 = { + key = "webhook.zip" + object_version = null + } + } + api_gateway_access_log_settings = { + destination_arn = aws_cloudwatch_log_group.webhook_access.arn + format = "$context.requestId" + } + memory_size = 512 + timeout = 10 + tags = { + Component = "webhook" + } + } + + scale_up = { + memory_size = 1024 + event_source_mapping = { + batch_size = 5 + } + } + + scale_down = { + memory_size = 512 + } + + pool = { + memory_size = 512 + } + } + + # Global v2 build-queue defaults. Visibility is independent of the + # scale-up Lambda timeout and must remain at least six times that timeout. + # Encryption is global-only; lanes cannot override it. + queue = { + delay_webhook_event = 30 + job_queue_retention_in_seconds = 86400 + visibility_timeout_seconds = 180 + redrive_build_queue = { + enabled = false + maxReceiveCount = null + } + tags = { + QueueOwner = "platform" + } + encryption = { + sqs_managed_sse_enabled = null + kms_master_key_id = aws_kms_key.github_app_parameters.arn + kms_data_key_reuse_period_seconds = 300 + } + } + + # Shared resources append app/webhook. Runner lanes append their lane key. + ssm = { + paths = { + root = "/github-actions" + app = "app" + webhook = "webhook" + } + + # This ARN-valued scalar may be unknown until apply. It encrypts shared + # app parameters, configures the webhook and termination watcher, and + # grants runner-stack decrypt access. + kms_key_id = aws_kms_key.github_app_parameters.arn + + parameters = { + tags = { + DataClass = "runner-runtime" + } + } + + housekeeper = { + schedule_expression = "rate(12 hours)" + lambda = { + memory_size = 512 + } + } + } + + # Omitted observability fields retain the v2 schema defaults. For example, + # metrics default to disabled with the "GitHub Runners" namespace, while + # each individual metric switch defaults to enabled. + observability = { + logs = { + level = "info" + retention_in_days = 30 + tags = { + LogOwner = "platform" + } + } + tracing = { + mode = "Active" + } + metrics = { + enable = true + namespace = "GitHub Runners" + } + } + + # Shared v2 EC2 defaults. This block neither selects EC2 nor supplies + # lane-required provider fields. Runner-binary settings are global because + # each syncer is shared by lanes with the same OS and architecture. + compute_provider = { + ec2 = { + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + + ami = { + housekeeper = { + enabled = true + cleanup_config = { + minimumDaysOld = 30 + dryRun = true + } + artifact = { + zip = null + s3 = { + key = "ami-housekeeper.zip" + object_version = null + } + } + lambda = { + memory_size = 256 + timeout = 300 + } + schedule = { + expression = "cron(11 7 * * ? *)" + } + } + } + + instance_termination_watcher = { + enabled = true + features = { + enable_spot_termination_handler = true + enable_spot_termination_notification_watcher = true + } + enable_runner_deregistration = true + environment_variables = {} + artifact = { + zip = null + s3 = { + key = "termination-watcher.zip" + object_version = null + } + } + lambda = { + memory_size = 512 + timeout = 30 + } + } + + runner_binaries = { + enabled = true + s3 = { + encryption = { + enabled = true + bucket_key_enabled = null + sse_algorithm = "AES256" + kms_master_key_id = null + } + tags = {} + versioning = "Disabled" + logging = { + bucket = null + prefix = null + } + } + syncer = { + # Both null selects the packaged syncer archive. Set at most one. + artifact = { + zip = null + s3 = null + } + lambda = { + memory_size = 256 + timeout = 300 + } + schedule = { + expression = "cron(27 * * * ? *)" + state = "ENABLED" + } + } + } + } + } + + multi_runner_config = { arm = { + tags = { + Environment = "arm-runners" + } + + # This lane inherits the global OS and architecture but raises its cap. runner = { - os = "linux" - architecture = "arm64" - maximum_count = 2 + maximum_count = 8 + } + + lambda = { + scale_up = { + memory_size = 1536 + } + } + + # A lane root is also a base; this resolves to + # /github-actions/high-capacity/arm for this entry. + ssm = { + paths = { + root = "/github-actions/high-capacity" + } + housekeeper = { + lambda = { + memory_size = 768 + } + } + } + + observability = { + logs = { + level = "debug" + } + metrics = { + namespace = "GitHub Runners Arm" + } } + # Each lane selects exactly one provider and supplies its required + # provider-specific values here. compute_provider = { ec2 = { instance_types = ["m7g.large"] @@ -117,23 +431,43 @@ 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.`. Exactly one typed provider block must be populated; that block selects the provider without a second discriminator field. +The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `enterprise_server`, `user_agent`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider`, in addition to its lane map at `multi_runner_config`. Global `lambda` settings include runner-stack artifact selection, the shared artifact bucket, runtime, architecture, principals, networking, role, and tag values plus nested `scale_up`, `scale_down`, `webhook`, and `pool` blocks. Runtime, architecture, networking, role, and tag globals configure v2 runner stacks and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. `lambda.principals` configures v2 runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Root `webhook` owns shared routing and matcher storage; `lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. The termination watcher, AMI housekeeper, and runner-binary syncer have nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. -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. +Each v2 lane groups provider-neutral settings by owner: `runner`, `github`, `queue`, `lambda` (with nested `scale_up`, `scale_down`, and `pool`), `job_retry`, `ssm`, and `observability`. Backend settings live under `compute_provider.`. A nullable lane field inherits its corresponding experimental global when omitted or null, except that an external lane runner role suppresses inherited IAM management inputs. Precedence within a runner stack is therefore a non-null lane override followed by the global nested value, including that field's nested schema default. Per-lane precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The lane runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. -Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and shared log-group tags. +Global `experimental.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null lane redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Lane fields under `experimental.multi_runner_config[].queue` override those global defaults, and lane queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].lambda.scale_up.timeout` controls the function only, while `experimental.multi_runner_config[].queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. -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.`. The provider key is derived dynamically from the selected input block and therefore also identifies the compute provider. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while an EC2 selection places launch-template and runner-log artifacts under `runners_map_v2["configuration"].provider.ec2`. The `pool` value is null when no pool configuration is supplied. +Queue encryption is global-only. Omitting the entire `experimental.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures only the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues. Lanes cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent. A distinct queue CMK reaches SQS, but current v2 webhook, scale-up, and job-retry IAM policies do not derive KMS grants from it; callers must grant those roles the required key permissions. The v1 translation retains the flat contract: lane delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. -## Plan-time provider selection and ownership wrappers +Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner stacks: `app` is required and `additional_apps` defaults to `[]`. `github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. Root `experimental.enterprise_server.url` defaults to `null` and configures both runner-stack GitHub clients and the shared termination watcher. `experimental.enterprise_server.ssl_verify` defaults to `true`, and `experimental.user_agent` defaults to `github-aws-runners`; both remain runner-stack client settings. Per-lane `github.organization_runners` remains a separate lane-owned registration-scope setting; lanes do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. -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: +Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner stack consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. + +Global `experimental.webhook` configures the shared webhook's queue-selection strategy, EventBridge implementation and accepted events, and matcher-configuration Parameter Store tier. `webhook.eventbridge.enable` and the matcher tier must be known during planning because they select module or parameter-chunk shape. `first` deterministically chooses the first equally matched queue by priority, `random` spreads jobs among equals, and `all` sends a job to every match at the cost of multiple runner launches and registrations. + +Global `ssm.paths.root` is the base for shared GitHub App and webhook parameters and for every lane. Shared resources append the global-only `ssm.paths.app` and `ssm.paths.webhook` segments, which default to `app` and `webhook`; normalization appends the lane key only for lane-owned roots, keeping runner parameters isolated. The derived global base defaults to `/github-action-runners/${prefix}`, while lane token and config segments default to `runners/tokens` and `runners/config`. Global `ssm.tags` augments the base tags on the shared Parameter Store module and also defaults lane-owned SSM tags; `ssm.parameters.tags` remains specific to lane-managed and runtime-created runner parameters. The global housekeeper defaults preserve the established schedule, enabled state, Lambda sizing, and cleanup behavior; a nullable lane field inherits those values. Avoid setting a global `ssm.housekeeper.config.tokenPath` unless every lane is intentionally meant to clean the same path; omitting it lets each stack derive its isolated token path. + +Global `observability` values provide defaults for every runner stack and configure the applicable shared singleton consumers. Log level, retention, KMS key, class, and tracing configure the webhook, runner-binary syncer, termination watcher, and AMI housekeeper; metrics also configure the termination watcher. The nested defaults preserve established behavior: logs use level `info`, 180-day retention, no customer-managed KMS key, and class `STANDARD`; tracing defaults to no mode with HTTP and error capture disabled; metrics default to disabled in the `GitHub Runners` namespace while the rate-limit, job-retry, Spot-termination, and Spot-warning switches default to enabled. The two Spot switches are global termination-watcher settings and have no lane override. Other nullable lane observability fields inherit the global value. `observability.logs.tags` remains specific to lane-owned runner-stack log groups; shared singleton functions receive global `tags` and `lambda.tags`. The nullness of `observability.tracing.mode` must be known during planning because it selects X-Ray IAM statements and tracing blocks in runner-stack consumers. + +The global `experimental.compute_provider` block owns v2 defaults for EC2 settings such as VPC and subnet IDs, managed-security-group behavior, egress rules, additional security groups, CloudWatch agent configuration, instance-profile path, key name, public IPv4 association, and tags. It also owns the shared AMI housekeeper, instance-termination watcher, and runner-binary distribution. It does not fall back to corresponding flat module inputs. VPC and subnet values must therefore be supplied through the global or lane EC2 block when needed. Global values should be set only when they are shared across every applicable lane. The global block never selects a provider and does not contain provider-specific required lane fields. Every lane must still populate exactly one typed provider block; that per-lane block selects the provider, supplies required fields such as EC2 `instance_types`, and preserves support for mixed-provider maps. + +`experimental.compute_provider.ec2.runner_binaries` owns whether EC2 lanes use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable lane `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. + +Runner-stack control-plane artifacts are selected globally through `experimental.lambda.scale.artifact.zip` or `experimental.lambda.scale.artifact.s3.{key,object_version}`. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. The webhook artifact remains separate under `lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. + +Tags follow the same ownership model but merge rather than replace. Within v2 queue and runner-stack scopes, experimental global tags merge with lane tags and then with component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes: shared SSM merges `experimental.tags` with `ssm.tags`, the webhook merges `lambda.tags` with `lambda.webhook.tags`, and the runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags` and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with lane `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. + +Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and lane log-group tags. Tracing stays under `observability.tracing`, and metrics enablement, namespace, and individual metric switches stay under `observability.metrics`. + +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.`. The provider key is derived from the selected typed input block. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while 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. + +## Plan-time provider selection and IAM shape + +Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Provider ownership inputs continue to use caller-known wrapper objects as discriminators. The shared SSM key is different: every relevant IAM consumer keeps a static statement and substitutes a harmless sentinel resource when the key is null, so `ssm.kms_key_id` can be an ARN-valued scalar that remains unknown until apply. The relevant configuration fragments are: ```hcl ssm = { - kms_key = { - arn = aws_kms_key.runner_parameters.arn - } + kms_key_id = aws_kms_key.runner_parameters.arn } compute_provider = { @@ -150,15 +484,15 @@ compute_provider = { } ``` -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. +The populated `ec2` block tells both multi-runner routing and runner-stack dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_stacks` input forwards it unchanged at the runner-stack boundary. Within the provider block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. `ssm.kms_key_id` and values such as `observability.logs.kms_key_id`, which configure existing static resource or policy shapes, 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. +For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts the shared GitHub App parameters, configures the webhook and termination watcher with the same key, and adds matching decrypt permissions to every runner stack so its control-plane functions can read those credentials. Its value may be unknown until apply because those IAM consumers retain a static statement shape. It does not select encryption for runtime-created lane runner parameters. Queue encryption is a separate global contract and may use a different CMK. ## Migration phases -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. +1. **Phase 1 — v2 opt-in and canonical translation (current):** A non-empty experimental map opts the whole module instance into v2, while an empty map preserves the existing `module.runners["configuration"]` addresses. Both stable and experimental inputs already resolve through the same canonical pipeline. The v2 switch is not an in-place state migration. +2. **Phase 2 — deprecate legacy variables:** Deprecate the stable `multi_runner_config` and migrated flat inputs while retaining both dispatch paths and compatibility outputs for a release window. +3. **Phase 3 — remove legacy variables and migrate state:** In a breaking release, remove the deprecated inputs and flat output adapter, route the remaining canonical configuration through `runner-stack`, and ship tested `moved` blocks plus commands for addresses Terraform cannot move declaratively. +4. **Phase 4 — remove `modules/runners`:** After direct consumers have had a separate deprecation and migration window, delete the legacy module. -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. +A future compute provider must add a typed external input block, multi-runner normalization and routing, and an integration that returns the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Populating more than one external provider block, or selecting a block whose resources are not implemented, is intentionally rejected. From 50ecc055a94b67f65feefdafde40b3fd37e83526 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:51:57 +0000 Subject: [PATCH 15/49] docs: auto update terraform docs --- modules/multi-runner/README.md | 16 ++++++++-------- .../fixtures/computed-runner-inputs/README.md | 10 +++++----- modules/runner-stack/README.md | 14 +++++++------- modules/runner-stack/job-retry/README.md | 12 ++++++------ modules/runner-stack/pool/README.md | 10 +++++----- modules/runner-stack/scale-runners/README.md | 12 ++++++------ modules/runner-stack/ssm-housekeeper/README.md | 10 +++++----- modules/termination-watcher/README.md | 12 ++++++------ 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index ae2ab09426..3db7e4f6b2 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,15 +167,15 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | ## 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 | @@ -187,7 +187,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 | @@ -199,7 +199,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -284,7 +284,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/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 25f3f220b2..5695a15af3 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,26 +2,26 @@ ## 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 | -| ---- | ------ | ------- | +|------|--------|---------| | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs @@ -31,7 +31,7 @@ No inputs. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | | [runner\_stack\_keys](#output\_runner\_stack\_keys) | n/a | \ No newline at end of file diff --git a/modules/runner-stack/README.md b/modules/runner-stack/README.md index 2363e5b8cc..92a1ddb42c 100644 --- a/modules/runner-stack/README.md +++ b/modules/runner-stack/README.md @@ -65,20 +65,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.59.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | @@ -89,7 +89,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 | @@ -102,7 +102,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.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 | @@ -122,7 +122,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | | [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 981c330e5e..0bda4e9442 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/runner-stack/job-retry/README.md @@ -11,15 +11,15 @@ 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.60.0 | +|------|---------| +| [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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `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)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(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_id = 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
})
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/pool/README.md b/modules/runner-stack/pool/README.md index c2d96a7eba..7ac9036acb 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters. The ARN may be unknown until apply; IAM policy shape remains static.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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_id = optional(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,6 +59,6 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/runner-stack/scale-runners/README.md b/modules/runner-stack/scale-runners/README.md index f4b92c7127..7dc16a4509 100644 --- a/modules/runner-stack/scale-runners/README.md +++ b/modules/runner-stack/scale-runners/README.md @@ -10,15 +10,15 @@ 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.59.0 | +|------|---------| +| [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.
- `lambda.role.principals`: Additional principals allowed to assume 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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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 = list(map(string))
id = list(map(string))
installation_id = list(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_id = 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
})
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 863e639ec3..bb3082e27f 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.
- `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda 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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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/termination-watcher/README.md b/modules/termination-watcher/README.md index f52640b4bc..ab751ddc8a 100644 --- a/modules/termination-watcher/README.md +++ b/modules/termination-watcher/README.md @@ -59,20 +59,20 @@ yarn run dist ## 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 | -| ---- | ------ | ------- | +|------|--------|---------| | [deregister\_retry\_lambda](#module\_deregister\_retry\_lambda) | ../lambda | n/a | | [termination\_handler](#module\_termination\_handler) | ./termination | n/a | | [termination\_notification](#module\_termination\_notification) | ./notification | n/a | @@ -80,7 +80,7 @@ yarn run dist ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_iam_role_policy.deregister_retry_ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.deregister_retry_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.deregister_retry_ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -93,13 +93,13 @@ yarn run dist ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Configuration for the spot termination watcher.

`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.
'features': Features to enable the different lambda functions to handle spot termination events.
`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'.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`ssm_kms_key_id`: Optional KMS key ARN used to decrypt GitHub App parameters while deregistering runners. The ARN may be unknown until apply.
`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.
`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`.
`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.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`github_app_parameters`: GitHub App SSM parameters (`id` and `key_base64`, each a map of `arn`/`name`) used to authenticate to GitHub when deregistering runners.
`ghes_url`: GitHub Enterprise Server URL used to target the GHES API when deregistering runners. Leave `null` for github.com. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
environment_variables = optional(map(string), {})
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
ssm_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, "GitHub Runners")
metric = optional(object({
enable_spot_termination = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
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, null)
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), [])
tag_filters = optional(map(string), null)
tags = optional(map(string), {})
timeout = optional(number, null)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
enable_runner_deregistration = optional(bool, false)
github_app_parameters = optional(object({
id = map(string)
key_base64 = map(string)
}), null)
ghes_url = optional(string, null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [deregister\_retry](#output\_deregister\_retry) | n/a | | [spot\_termination\_handler](#output\_spot\_termination\_handler) | n/a | | [spot\_termination\_notification](#output\_spot\_termination\_notification) | n/a | From f7e0ed738e9af5391222173ccc85d9baa688bf03 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 04:23:02 +0200 Subject: [PATCH 16/49] docs(multi-runner): restore stable input descriptions --- modules/multi-runner/README.md | 10 +++++----- modules/multi-runner/variables.tf | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 3db7e4f6b2..6d58c6671c 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -223,13 +223,13 @@ module "multi-runner" { | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`environment_variables`: Additional environment variables to merge into the Lambda configuration.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no | | [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | -| [kms\_key\_arn](#input\_kms\_key\_arn) | Stable/v1 optional KMS key ARN for Parameter Store. Experimental v2 uses `experimental.ssm.kms_key_id`; this flat value only seeds stable-mode translation. | `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\_principals](#input\_lambda\_principals) | (Optional) add extra principals to the role created for execution of the lambda, e.g. for local testing. |
list(object({
type = string
identifiers = list(string)
}))
| `[]` | no | | [lambda\_runtime](#input\_lambda\_runtime) | AWS Lambda runtime. | `string` | `"nodejs24.x"` | no | -| [lambda\_s3\_bucket](#input\_lambda\_s3\_bucket) | Stable/v1 S3 bucket containing Lambda artifacts. A non-null value takes precedence over flat local-zip inputs during stable-mode translation. Experimental v2 uses the shared `experimental.lambda.artifact.s3.bucket`. | `string` | `null` | 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\_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 action runners 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 | @@ -257,9 +257,9 @@ module "multi-runner" { | [runner\_binaries\_syncer\_lambda\_zip](#input\_runner\_binaries\_syncer\_lambda\_zip) | File location of the binaries sync lambda zip file. | `string` | `null` | no | | [runner\_binaries\_syncer\_memory\_size](#input\_runner\_binaries\_syncer\_memory\_size) | Memory size limit in MB for binary syncer lambda. | `number` | `256` | no | | [runner\_egress\_rules](#input\_runner\_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 | -| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | Stable/v1 S3 key for the scaling control-plane Lambda archive. Used when `lambda_s3_bucket` is set. Experimental v2 uses `experimental.lambda.scale.artifact.s3.key`. | `string` | `null` | no | -| [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | Stable/v1 optional object version for the scaling control-plane Lambda archive. Experimental v2 uses `experimental.lambda.scale.artifact.s3.object_version`. | `string` | `null` | no | -| [runners\_lambda\_zip](#input\_runners\_lambda\_zip) | Stable/v1 local scaling control-plane Lambda archive. A configured `lambda_s3_bucket` takes precedence. Experimental v2 uses `experimental.lambda.scale.artifact.zip`. | `string` | `null` | 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\_lambda\_zip](#input\_runners\_lambda\_zip) | File location of the lambda zip file for scaling runners. | `string` | `null` | no | | [runners\_scale\_down\_lambda\_timeout](#input\_runners\_scale\_down\_lambda\_timeout) | Time out for the scale down lambda in seconds. | `number` | `60` | no | | [runners\_scale\_up\_lambda\_timeout](#input\_runners\_scale\_up\_lambda\_timeout) | Time out for the scale up lambda in seconds. | `number` | `30` | no | | [runners\_ssm\_housekeeper](#input\_runners\_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.
`enabled`: enable or disable the lambda trigger via the EventBridge.
`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)")
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 | diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index d766bf90a0..97f9785d45 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -75,7 +75,7 @@ variable "prefix" { } variable "kms_key_arn" { - description = "Stable/v1 optional KMS key ARN for Parameter Store. Experimental v2 uses `experimental.ssm.kms_key_id`; this flat value only seeds stable-mode translation." + description = "Optional CMK Key ARN to be used for Parameter Store." type = string default = null } @@ -411,7 +411,7 @@ variable "log_class" { } variable "lambda_s3_bucket" { - description = "Stable/v1 S3 bucket containing Lambda artifacts. A non-null value takes precedence over flat local-zip inputs during stable-mode translation. Experimental v2 uses the shared `experimental.lambda.artifact.s3.bucket`." + description = "S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly." type = string default = null } @@ -639,19 +639,19 @@ variable "runner_additional_security_group_ids" { } variable "runners_lambda_s3_key" { - description = "Stable/v1 S3 key for the scaling control-plane Lambda archive. Used when `lambda_s3_bucket` is set. Experimental v2 uses `experimental.lambda.scale.artifact.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 = "Stable/v1 optional object version for the scaling control-plane Lambda archive. Experimental v2 uses `experimental.lambda.scale.artifact.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 "runners_lambda_zip" { - description = "Stable/v1 local scaling control-plane Lambda archive. A configured `lambda_s3_bucket` takes precedence. Experimental v2 uses `experimental.lambda.scale.artifact.zip`." + description = "File location of the lambda zip file for scaling runners." type = string default = null } From 704fc6edd84103b1e31ec20f1f8bae0505eda5d8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 04:41:51 +0200 Subject: [PATCH 17/49] refactor(multi-runner): isolate termination watcher fix --- docs/index.md | 2 +- docs/modules/internal/compute-provider-refactor.md | 6 +++--- modules/multi-runner/README.md | 4 ++-- modules/multi-runner/termination-watcher.tf | 1 - modules/multi-runner/tests/provider-routing.tftest.hcl | 4 +--- modules/multi-runner/variables.experimental.tf | 2 +- modules/termination-watcher/README.md | 2 +- modules/termination-watcher/deregister-retry.tf | 5 ----- modules/termination-watcher/main.tf | 8 ++++++-- modules/termination-watcher/notification/main.tf | 5 ----- modules/termination-watcher/termination/main.tf | 5 ----- modules/termination-watcher/variables.tf | 2 -- 12 files changed, 15 insertions(+), 31 deletions(-) diff --git a/docs/index.md b/docs/index.md index 6e1101e9c3..3e573d2d41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -115,7 +115,7 @@ The shared webhook, runner-binary syncer, termination watcher, and AMI housekeep `experimental.compute_provider.ec2.runner_binaries.enabled` defaults each EC2 lane to the shared synchronized distribution, while a nullable lane `compute_provider.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. -Global `ssm.paths.root` is the base for shared and lane-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the lane key only for lane-owned paths. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every runner stack; it does not select encryption for runtime-created lane runner parameters. IAM consumers keep a static policy shape with a harmless sentinel resource when the value is null, so a real ARN may remain unknown until apply. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than lane overrides. +Global `ssm.paths.root` is the base for shared and lane-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the lane key only for lane-owned paths. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner stack; it does not select encryption for runtime-created lane runner parameters. IAM consumers keep a static policy shape with a harmless sentinel resource when the value is null, so a real ARN may remain unknown until apply. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than lane overrides. Each lane still selects exactly one provider and supplies provider-specific required fields with its own `compute_provider` block. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider object reaches runner-stack, which also validates that exactly one typed block is populated before dispatch. The 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. Later releases will route the existing v1 translation through runner-stack, 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. diff --git a/docs/modules/internal/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md index 4d77613239..9b6bf59894 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -247,8 +247,8 @@ module "multi_runner" { } # This ARN-valued scalar may be unknown until apply. It encrypts shared - # app parameters, configures the webhook and termination watcher, and - # grants runner-stack decrypt access. + # app parameters, configures the webhook, and grants runner-stack + # decrypt access. kms_key_id = aws_kms_key.github_app_parameters.arn parameters = { @@ -486,7 +486,7 @@ compute_provider = { The populated `ec2` block tells both multi-runner routing and runner-stack dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_stacks` input forwards it unchanged at the runner-stack boundary. Within the provider block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. `ssm.kms_key_id` and values such as `observability.logs.kms_key_id`, which configure existing static resource or policy shapes, remain nullable scalar inputs. -For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts the shared GitHub App parameters, configures the webhook and termination watcher with the same key, and adds matching decrypt permissions to every runner stack so its control-plane functions can read those credentials. Its value may be unknown until apply because those IAM consumers retain a static statement shape. It does not select encryption for runtime-created lane runner parameters. Queue encryption is a separate global contract and may use a different CMK. +For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts the shared GitHub App parameters, configures the webhook with the same key, and adds matching decrypt permissions to every runner stack so its control-plane functions can read those credentials. Its value may be unknown until apply because those IAM consumers retain a static statement shape. It does not select encryption for runtime-created lane runner parameters. Queue encryption is a separate global contract and may use a different CMK. ## Migration phases diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 6d58c6671c..e55c34f4c1 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -33,7 +33,7 @@ V2 requires `experimental.github.app`, and `experimental.github.additional_apps` Shared singleton resources use the translated global contract without accepting per-lane overrides. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Runner-stack control-plane artifacts come from global `experimental.lambda.scale.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources unselected uses the packaged runner archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves legacy S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. Root `experimental.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier. `lambda.webhook` owns webhook artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. -Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for lane-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the lane key only to lane roots. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every runner stack. It does not select encryption for runtime-created lane runner parameters. IAM consumers retain a static statement with a harmless sentinel resource when the value is null, so the configured ARN may remain unknown until apply. +Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for lane-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the lane key only to lane roots. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner stack. It does not select encryption for runtime-created lane runner parameters. IAM consumers retain a static statement with a harmless sentinel resource when the value is null, so the configured ARN may remain unknown until apply. The 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. Each lane still selects its provider from exactly one populated typed block under its own `compute_provider`; the global provider block supplies v2 defaults only. The same wrapper reaches runner-stack, whose direct input contract also validates exactly one populated provider block before dispatch. The EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. The selected lane 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. @@ -215,7 +215,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`.
- `user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`.
- `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive.
- `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK.
- `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].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.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(number, null)
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
}), {})

enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})

user_agent = optional(string, "github-aws-runners")

webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

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

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), 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({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`.
- `user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`.
- `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive.
- `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK.
- `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].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.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(number, null)
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
}), {})

enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})

user_agent = optional(string, "github-aws-runners")

webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

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

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), 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({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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/termination-watcher.tf b/modules/multi-runner/termination-watcher.tf index 85c2f4320f..22ca4d7ee2 100644 --- a/modules/multi-runner/termination-watcher.tf +++ b/modules/multi-runner/termination-watcher.tf @@ -14,7 +14,6 @@ module "instance_termination_watcher" { log_level = local.translated_experimental.observability.logs.level log_class = local.translated_experimental.observability.logs.class logging_kms_key_id = local.translated_experimental.observability.logs.kms_key_id - ssm_kms_key_id = local.translated_experimental.ssm.kms_key_id logging_retention_in_days = local.translated_experimental.observability.logs.retention_in_days role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 508ac45f80..57cd7ceb4d 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -612,7 +612,6 @@ run "stable_v1_keeps_legacy_runner_module" { && output.instance_termination_watcher.lambda_role.path == "/legacy/" && output.instance_termination_watcher.lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://legacy.example.com" - && output.instance_termination_watcher.lambda.function.environment[0].variables["LEGACY_WATCHER"] == "true" && output.instance_termination_watcher.lambda.function.environment[0].variables["LOG_LEVEL"] == "warn" && output.instance_termination_watcher.lambda.function.tracing_config[0].mode == "Active" && output.instance_termination_watcher.lambda_log_group.retention_in_days == 14 @@ -1872,13 +1871,12 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { && length(output.instance_termination_watcher.lambda.function.vpc_config[0].security_group_ids) == 0 && output.instance_termination_watcher.lambda_role.path == "/experimental/" && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" - && output.instance_termination_watcher.lambda.function.environment[0].variables["NESTED_WATCHER"] == "true" && output.instance_termination_watcher.lambda.function.environment[0].variables["LOG_LEVEL"] == "info" && output.instance_termination_watcher.lambda_log_group.retention_in_days == 180 && output.instance_termination_watcher.lambda_log_group.log_group_class == "STANDARD" && output.instance_termination_handler == null ) - error_message = "V2 runner stacks and the termination watcher must use nested GitHub, Lambda, role, observability, feature, artifact, sizing, and environment settings without flat component fallback." + error_message = "V2 runner stacks and the termination watcher must use nested GitHub, Lambda, role, observability, feature, artifact, and sizing settings without flat component fallback." } assert { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index 492e9c2da4..53e42a3263 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -138,7 +138,7 @@ variable "experimental" { - `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`. - `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`. - `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`. - - `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters. + - `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters. - `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`. - `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`. - `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`. diff --git a/modules/termination-watcher/README.md b/modules/termination-watcher/README.md index ab751ddc8a..4cdf37f13b 100644 --- a/modules/termination-watcher/README.md +++ b/modules/termination-watcher/README.md @@ -94,7 +94,7 @@ yarn run dist | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration for the spot termination watcher.

`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.
'features': Features to enable the different lambda functions to handle spot termination events.
`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'.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`ssm_kms_key_id`: Optional KMS key ARN used to decrypt GitHub App parameters while deregistering runners. The ARN may be unknown until apply.
`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.
`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`.
`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.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`github_app_parameters`: GitHub App SSM parameters (`id` and `key_base64`, each a map of `arn`/`name`) used to authenticate to GitHub when deregistering runners.
`ghes_url`: GitHub Enterprise Server URL used to target the GHES API when deregistering runners. Leave `null` for github.com. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
environment_variables = optional(map(string), {})
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
ssm_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, "GitHub Runners")
metric = optional(object({
enable_spot_termination = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
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, null)
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), [])
tag_filters = optional(map(string), null)
tags = optional(map(string), {})
timeout = optional(number, null)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
enable_runner_deregistration = optional(bool, false)
github_app_parameters = optional(object({
id = map(string)
key_base64 = map(string)
}), null)
ghes_url = optional(string, null)
})
| n/a | yes | +| [config](#input\_config) | Configuration for the spot termination watcher.

`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.
'features': Features to enable the different lambda functions to handle spot termination events.
`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'.
`log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `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.
`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`.
`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.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`github_app_parameters`: GitHub App SSM parameters (`id` and `key_base64`, each a map of `arn`/`name`) used to authenticate to GitHub when deregistering runners.
`ghes_url`: GitHub Enterprise Server URL used to target the GHES API when deregistering runners. Leave `null` for github.com. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
environment_variables = optional(map(string), {})
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
lambda_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)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_spot_termination = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
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, null)
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), [])
tag_filters = optional(map(string), null)
tags = optional(map(string), {})
timeout = optional(number, null)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
enable_runner_deregistration = optional(bool, false)
github_app_parameters = optional(object({
id = map(string)
key_base64 = map(string)
}), null)
ghes_url = optional(string, null)
})
| n/a | yes | ## Outputs diff --git a/modules/termination-watcher/deregister-retry.tf b/modules/termination-watcher/deregister-retry.tf index a856274b0f..921d7abf5e 100644 --- a/modules/termination-watcher/deregister-retry.tf +++ b/modules/termination-watcher/deregister-retry.tf @@ -101,11 +101,6 @@ resource "aws_iam_role_policy" "deregister_retry_ssm" { Effect = "Allow" Action = ["ssm:GetParameter"] Resource = local.ssm_parameter_arns - }, - { - Effect = "Allow" - Action = ["kms:Decrypt"] - Resource = [local.config._ssm_kms_key_id] } ] }) diff --git a/modules/termination-watcher/main.tf b/modules/termination-watcher/main.tf index 145e19380b..919ba3a3e5 100644 --- a/modules/termination-watcher/main.tf +++ b/modules/termination-watcher/main.tf @@ -18,15 +18,19 @@ locals { var.config.github_app_parameters.key_base64.arn, ] : [] + environment_variables = { + ENABLE_METRICS_SPOT_WARNING = var.config.metrics != null ? var.config.metrics.enable && var.config.metrics.metric.enable_spot_termination_warning : false + TAG_FILTERS = jsonencode(var.config.tag_filters) + } + config = merge(var.config, { name = local.name, handler = "index.interruptionWarning", zip = local.lambda_zip, - environment_variables = var.config.environment_variables + environment_variables = local.environment_variables metrics_namespace = var.config.metrics.namespace _deregistration_env_vars = local.deregistration_env_vars _ssm_parameter_arns = local.ssm_parameter_arns - _ssm_kms_key_id = coalesce(var.config.ssm_kms_key_id, "arn:${coalesce(var.config.aws_partition, "aws")}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000") _enable_runner_deregistration = local.enable_runner_deregistration }) } diff --git a/modules/termination-watcher/notification/main.tf b/modules/termination-watcher/notification/main.tf index 7683a760f9..735c34126b 100644 --- a/modules/termination-watcher/notification/main.tf +++ b/modules/termination-watcher/notification/main.tf @@ -102,11 +102,6 @@ resource "aws_iam_role_policy" "ssm_policy" { Effect = "Allow" Action = ["ssm:GetParameter"] Resource = var.config._ssm_parameter_arns - }, - { - Effect = "Allow" - Action = ["kms:Decrypt"] - Resource = [var.config._ssm_kms_key_id] } ] }) diff --git a/modules/termination-watcher/termination/main.tf b/modules/termination-watcher/termination/main.tf index d96e6f820e..f43b61775a 100644 --- a/modules/termination-watcher/termination/main.tf +++ b/modules/termination-watcher/termination/main.tf @@ -66,11 +66,6 @@ resource "aws_iam_role_policy" "ssm_policy" { Effect = "Allow" Action = ["ssm:GetParameter"] Resource = var.config._ssm_parameter_arns - }, - { - Effect = "Allow" - Action = ["kms:Decrypt"] - Resource = [var.config._ssm_kms_key_id] } ] }) diff --git a/modules/termination-watcher/variables.tf b/modules/termination-watcher/variables.tf index 4d60abc5e8..a72bf74916 100644 --- a/modules/termination-watcher/variables.tf +++ b/modules/termination-watcher/variables.tf @@ -11,7 +11,6 @@ variable "config" { `log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. `log_class`: The log class of the CloudWatch log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. `logging_kms_key_id`: Specifies the kms key id to encrypt the logs with - `ssm_kms_key_id`: Optional KMS key ARN used to decrypt GitHub App parameters while deregistering runners. The ARN may be unknown until apply. `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. `prefix`: The prefix used for naming resources. @@ -44,7 +43,6 @@ variable "config" { log_level = optional(string, null) log_class = optional(string, "STANDARD") logging_kms_key_id = optional(string, null) - ssm_kms_key_id = optional(string, null) logging_retention_in_days = optional(number, null) memory_size = optional(number, null) metrics = optional(object({ From cbb66880344ac91856aa7779ed137ef880b0605d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 11:03:29 +0200 Subject: [PATCH 18/49] refactor(multi-runner): nest GitHub client settings --- .../config.experimental.translation.tf | 16 ++-- modules/multi-runner/runners.tf | 6 +- modules/multi-runner/termination-watcher.tf | 2 +- .../tests/provider-routing.tftest.hcl | 77 ++++++++++--------- .../multi-runner/variables.experimental.tf | 18 ++--- 5 files changed, 60 insertions(+), 59 deletions(-) diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 3fad9fd3c1..c7de9d073a 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -48,15 +48,13 @@ locals { app = var.github_app additional_apps = var.additional_github_apps repository_white_list = var.repository_white_list + enterprise_server = { + url = var.ghes_url + ssl_verify = var.ghes_ssl_verify + } + user_agent = var.user_agent } - enterprise_server = { - url = var.ghes_url - ssl_verify = var.ghes_ssl_verify - } - - user_agent = var.user_agent - webhook = { queue_selection_strategy = var.queue_selection_strategy eventbridge = var.eventbridge @@ -719,8 +717,8 @@ locals { }) github = merge(v.github, { - enterprise_server = local.translated_experimental_base.enterprise_server - user_agent = local.translated_experimental_base.user_agent + enterprise_server = local.translated_experimental_base.github.enterprise_server + user_agent = local.translated_experimental_base.github.user_agent }) queue = merge(v.queue, { diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 197e2d9090..fdb0c961f5 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -119,9 +119,9 @@ module "runners" { runner_role_arn = try(each.value.runner.iam.role.arn, null) } - ghes_url = local.translated_experimental.enterprise_server.url - ghes_ssl_verify = local.translated_experimental.enterprise_server.ssl_verify - user_agent = local.translated_experimental.user_agent + ghes_url = local.translated_experimental.github.enterprise_server.url + ghes_ssl_verify = local.translated_experimental.github.enterprise_server.ssl_verify + user_agent = local.translated_experimental.github.user_agent kms_key_arn = local.translated_experimental.ssm.kms_key_id diff --git a/modules/multi-runner/termination-watcher.tf b/modules/multi-runner/termination-watcher.tf index 22ca4d7ee2..4fd3227a40 100644 --- a/modules/multi-runner/termination-watcher.tf +++ b/modules/multi-runner/termination-watcher.tf @@ -32,7 +32,7 @@ module "instance_termination_watcher" { id = local.github_app_parameters.id[0] key_base64 = local.github_app_parameters.key_base64[0] } : null - ghes_url = local.translated_experimental.enterprise_server.url + ghes_url = local.translated_experimental.github.enterprise_server.url environment_variables = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables } } diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 57cd7ceb4d..5503c36b82 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -210,12 +210,12 @@ run "stable_v1_keeps_legacy_runner_module" { id = "incomplete-experimental-id" } additional_apps = [{ id = "incomplete-additional-app-id" }] + enterprise_server = { + url = "https://experimental.example.com" + ssl_verify = true + } + user_agent = "experimental-user-agent" } - enterprise_server = { - url = "https://experimental.example.com" - ssl_verify = true - } - user_agent = "experimental-user-agent" ssm = { paths = { root = "relative-experimental-root" @@ -299,8 +299,6 @@ run "stable_v1_keeps_legacy_runner_module" { "roles", "runner", "github", - "enterprise_server", - "user_agent", "webhook", "lambda", "queue", @@ -309,6 +307,13 @@ run "stable_v1_keeps_legacy_runner_module" { "compute_provider", "multi_runner_config", ]) + && toset(keys(local.raw_translated_experimental.github)) == toset([ + "app", + "additional_apps", + "repository_white_list", + "enterprise_server", + "user_agent", + ]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"])) == toset([ "tags", "runner", @@ -374,9 +379,9 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.github.app == var.github_app && local.raw_translated_experimental.github.additional_apps == var.additional_github_apps && local.raw_translated_experimental.github.repository_white_list == var.repository_white_list - && local.raw_translated_experimental.enterprise_server.url == var.ghes_url - && local.raw_translated_experimental.enterprise_server.ssl_verify == var.ghes_ssl_verify - && local.raw_translated_experimental.user_agent == var.user_agent + && local.raw_translated_experimental.github.enterprise_server.url == var.ghes_url + && local.raw_translated_experimental.github.enterprise_server.ssl_verify == var.ghes_ssl_verify + && local.raw_translated_experimental.github.user_agent == var.user_agent && local.raw_translated_experimental.webhook.queue_selection_strategy == var.queue_selection_strategy && local.raw_translated_experimental.webhook.eventbridge == var.eventbridge && local.raw_translated_experimental.webhook.matcher_config_parameter_store_tier == var.matcher_config_parameter_store_tier @@ -1140,11 +1145,11 @@ run "experimental_v2_routes_through_provider_stack" { local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps && length(local.translated_experimental.github.repository_white_list) == 0 - && local.translated_experimental.enterprise_server == var.experimental.enterprise_server - && local.translated_experimental.user_agent == var.experimental.user_agent - && local.translated_experimental.enterprise_server.url == null - && local.translated_experimental.enterprise_server.ssl_verify - && local.translated_experimental.user_agent == "github-aws-runners" + && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server + && local.translated_experimental.github.user_agent == var.experimental.github.user_agent + && local.translated_experimental.github.enterprise_server.url == null + && local.translated_experimental.github.enterprise_server.ssl_verify + && local.translated_experimental.github.user_agent == "github-aws-runners" && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["GHES_URL"] == null && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "1" && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" @@ -1498,12 +1503,12 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { webhook_secret = "test-secret" } repository_white_list = ["nested-owner/nested-repository"] + enterprise_server = { + url = "https://experimental-shared.example.com" + ssl_verify = false + } + user_agent = "experimental-runner-user-agent" } - enterprise_server = { - url = "https://experimental-shared.example.com" - ssl_verify = false - } - user_agent = "experimental-runner-user-agent" webhook = { queue_selection_strategy = "all" @@ -1845,11 +1850,11 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps - && local.translated_experimental.enterprise_server == var.experimental.enterprise_server - && local.translated_experimental.user_agent == var.experimental.user_agent - && local.translated_experimental.enterprise_server.url == "https://experimental-shared.example.com" - && !local.translated_experimental.enterprise_server.ssl_verify - && local.translated_experimental.user_agent == "experimental-runner-user-agent" + && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server + && local.translated_experimental.github.user_agent == var.experimental.github.user_agent + && local.translated_experimental.github.enterprise_server.url == "https://experimental-shared.example.com" + && !local.translated_experimental.github.enterprise_server.ssl_verify + && local.translated_experimental.github.user_agent == "experimental-runner-user-agent" && length(local.translated_experimental.github.additional_apps) == 0 && local.translated_experimental.multi_runner_config["resolved"].job_retry.enabled && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" @@ -1989,12 +1994,12 @@ run "experimental_v2_layers_observability_and_ssm" { key_base64 = "dGVzdA==" webhook_secret = "test-secret" } + enterprise_server = { + url = "https://experimental-observability.example.com" + ssl_verify = false + } + user_agent = "experimental-observability-user-agent" } - enterprise_server = { - url = "https://experimental-observability.example.com" - ssl_verify = false - } - user_agent = "experimental-observability-user-agent" lambda = { scale = { @@ -2721,9 +2726,9 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled key_base64 = "dGVzdA==" webhook_secret = "test-secret" } - } - enterprise_server = { - url = "https://experimental-disabled-deregistration.example.com" + enterprise_server = { + url = "https://experimental-disabled-deregistration.example.com" + } } lambda = { scale = { @@ -2802,9 +2807,9 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { key_base64 = "dGVzdA==" webhook_secret = "test-secret" } - } - enterprise_server = { - url = "https://experimental-watcher.example.com" + enterprise_server = { + url = "https://experimental-watcher.example.com" + } } lambda = { scale = { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index 53e42a3263..b8b70117cd 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -62,9 +62,9 @@ variable "experimental" { - `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter. - `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter. - `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering. - - `enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null. - - `enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`. - - `user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`. + - `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null. + - `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`. + - `github.user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`. - `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job. - `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. - `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events. @@ -499,15 +499,13 @@ variable "experimental" { installation_id_ssm = optional(object({ arn = string, name = string })) })), []) repository_white_list = optional(list(string), []) + enterprise_server = optional(object({ + url = optional(string, null) + ssl_verify = optional(bool, true) + }), {}) + user_agent = optional(string, "github-aws-runners") }), {}) - enterprise_server = optional(object({ - url = optional(string, null) - ssl_verify = optional(bool, true) - }), {}) - - user_agent = optional(string, "github-aws-runners") - webhook = optional(object({ queue_selection_strategy = optional(string, "first") eventbridge = optional(object({ From 30ab11ba63de454ec6ca62153f35a2bf0d99efb8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 11:18:29 +0200 Subject: [PATCH 19/49] docs(multi-runner): align experimental GitHub paths --- docs/index.md | 4 ++-- .../internal/compute-provider-refactor.md | 18 +++++++++--------- modules/multi-runner/README.md | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/index.md b/docs/index.md index 3e573d2d41..0ab566e66e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -105,11 +105,11 @@ Currently we support two main modules. The existing `runners` module remains the Multi-runner centralizes mode selection and canonical configuration in `config.experimental.translation.tf`. A non-empty experimental lane map selects v2; otherwise the file projects the flat globals and stable lanes into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base` by applying schema defaults, global/lane precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults. Provider selection and the shared runner-binary syncer and discovery use this plan-known base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-stack GitHub client settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume that final canonical representation. Stable lanes are adapted back into the existing `module.runners["configuration"]` call, preserving its Terraform addresses while removing a second configuration path. The `module.runner_stacks` call directly iterates the gated final lanes, inlines the environment tag and live GitHub App and build-queue references, exposes the scale-up, scale-down, and pool aliases expected by runner-stack, and forwards the remaining canonical objects, including the typed `compute_provider = { ec2 = ... }` wrapper. -The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `enterprise_server`, `user_agent`, `webhook`, `lambda` (including nested `scale_up`, `scale_down`, `webhook`, and `pool` settings), `queue`, `ssm`, `observability`, and `compute_provider`. These globals configure v2 runner stacks and the applicable singleton shared components. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides remain lane-only. A nullable lane field with a corresponding experimental global inherits that global value when omitted or null. A lane that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. +The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `webhook`, `lambda` (including nested `scale_up`, `scale_down`, `webhook`, and `pool` settings), `queue`, `ssm`, `observability`, and `compute_provider`. These globals configure v2 runner stacks and the applicable singleton shared components. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides remain lane-only. A nullable lane field with a corresponding experimental global inherits that global value when omitted or null. A lane that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Global `experimental.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Lane `experimental.multi_runner_config[].queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures only the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues, and its CMK is independent from `experimental.ssm.kms_key_id`. The current v2 webhook, scale-up, and job-retry IAM policies do not derive KMS grants from a distinct queue CMK, so callers must grant those roles the required key permissions. For v2, `experimental.multi_runner_config[].queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].lambda.scale_up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner stacks consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.enterprise_server.url` defaults to `null` and configures v2 runner-stack GitHub clients and the termination watcher. `experimental.enterprise_server.ssl_verify` and `experimental.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner stacks consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-stack GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Runner-stack control-plane artifact selection is global under `experimental.lambda.scale.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. Root `experimental.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. diff --git a/docs/modules/internal/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md index 9b6bf59894..e1d3835675 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -137,15 +137,15 @@ module "multi_runner" { repository_white_list = [ "example/example-repository", ] - } - # The URL also configures the shared termination watcher. TLS verification - # and the User-Agent remain runner-stack GitHub-client settings. - enterprise_server = { - url = var.ghes_url - ssl_verify = true + # The URL also configures the shared termination watcher. TLS verification + # and the User-Agent remain runner-stack GitHub-client settings. + enterprise_server = { + url = var.ghes_url + ssl_verify = true + } + user_agent = "github-aws-runners" } - user_agent = "github-aws-runners" # Shared-webhook routing and matcher storage are global-only. webhook = { @@ -431,7 +431,7 @@ module "multi_runner" { ## Inputs, tags, and outputs -The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `enterprise_server`, `user_agent`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider`, in addition to its lane map at `multi_runner_config`. Global `lambda` settings include runner-stack artifact selection, the shared artifact bucket, runtime, architecture, principals, networking, role, and tag values plus nested `scale_up`, `scale_down`, `webhook`, and `pool` blocks. Runtime, architecture, networking, role, and tag globals configure v2 runner stacks and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. `lambda.principals` configures v2 runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Root `webhook` owns shared routing and matcher storage; `lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. The termination watcher, AMI housekeeper, and runner-binary syncer have nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. +The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider`, in addition to its lane map at `multi_runner_config`. Global `lambda` settings include runner-stack artifact selection, the shared artifact bucket, runtime, architecture, principals, networking, role, and tag values plus nested `scale_up`, `scale_down`, `webhook`, and `pool` blocks. Runtime, architecture, networking, role, and tag globals configure v2 runner stacks and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. `lambda.principals` configures v2 runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Root `webhook` owns shared routing and matcher storage; `lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. The termination watcher, AMI housekeeper, and runner-binary syncer have nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. Each v2 lane groups provider-neutral settings by owner: `runner`, `github`, `queue`, `lambda` (with nested `scale_up`, `scale_down`, and `pool`), `job_retry`, `ssm`, and `observability`. Backend settings live under `compute_provider.`. A nullable lane field inherits its corresponding experimental global when omitted or null, except that an external lane runner role suppresses inherited IAM management inputs. Precedence within a runner stack is therefore a non-null lane override followed by the global nested value, including that field's nested schema default. Per-lane precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The lane runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. @@ -439,7 +439,7 @@ Global `experimental.queue` owns v2 build-queue defaults. `delay_webhook_event` Queue encryption is global-only. Omitting the entire `experimental.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures only the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues. Lanes cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent. A distinct queue CMK reaches SQS, but current v2 webhook, scale-up, and job-retry IAM policies do not derive KMS grants from it; callers must grant those roles the required key permissions. The v1 translation retains the flat contract: lane delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. -Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner stacks: `app` is required and `additional_apps` defaults to `[]`. `github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. Root `experimental.enterprise_server.url` defaults to `null` and configures both runner-stack GitHub clients and the shared termination watcher. `experimental.enterprise_server.ssl_verify` defaults to `true`, and `experimental.user_agent` defaults to `github-aws-runners`; both remain runner-stack client settings. Per-lane `github.organization_runners` remains a separate lane-owned registration-scope setting; lanes do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. +Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner stacks: `app` is required and `additional_apps` defaults to `[]`. `github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-stack GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-stack client settings. Per-lane `github.organization_runners` remains a separate lane-owned registration-scope setting; lanes do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner stack consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index e55c34f4c1..307fbc8e96 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -25,11 +25,11 @@ 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. -A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-stack` at `module.runner_stacks["configuration"]`. Sibling `experimental.tags`, `roles`, `runner`, `github`, `enterprise_server`, `user_agent`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each lane can override supported lane-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides affect only that lane, never a singleton shared component. A nullable lane field inherits its global value. Within v2 queue and runner-stack scopes, tags merge from global to lane and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. +A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-stack` at `module.runner_stacks["configuration"]`. Sibling `experimental.tags`, `roles`, `runner`, `github`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each lane can override supported lane-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides affect only that lane, never a singleton shared component. A nullable lane field inherits its global value. Within v2 queue and runner-stack scopes, tags merge from global to lane and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental lane map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, global/lane precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-stack GitHub client settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable lanes are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_stacks` call directly iterates the gated final lanes, inlines the environment tag and live GitHub App and build-queue references, exposes the scale-up, scale-down, and pool aliases expected by `runner-stack`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner stacks; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.enterprise_server.url` defaults to `null` and configures both v2 runner-stack GitHub clients and the shared termination watcher. `experimental.enterprise_server.ssl_verify` and `experimental.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner stacks; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-stack GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. Shared singleton resources use the translated global contract without accepting per-lane overrides. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Runner-stack control-plane artifacts come from global `experimental.lambda.scale.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources unselected uses the packaged runner archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves legacy S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. Root `experimental.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier. `lambda.webhook` owns webhook artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. @@ -215,7 +215,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`.
- `user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`.
- `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive.
- `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK.
- `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].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.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(number, null)
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
}), {})

enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})

user_agent = optional(string, "github-aws-runners")

webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

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

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), 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({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`.
- `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive.
- `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK.
- `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].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.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(number, null)
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

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

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), 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({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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 | From da77c1370455513e6b90b2e1ae6b2e6bf9f3e326 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 11:36:10 +0200 Subject: [PATCH 20/49] fix(multi-runner): allow v2 to omit flat inputs --- .../fixtures/computed-runner-inputs/main.tf | 10 +-- .../tests/provider-routing.tftest.hcl | 88 ++++++++++++++++--- .../multi-runner/validations.experimental.tf | 26 ++++++ modules/multi-runner/variables.tf | 14 +-- 4 files changed, 108 insertions(+), 30 deletions(-) diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf index 3591dcc20d..cd6062d497 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -1,5 +1,5 @@ # Keep the runner lane key, provider selection, and optional KMS scalar -# caller-known while passing apply-time ARNs through the configuration. +# caller-known while passing apply-time ARNs through the experimental configuration. resource "random_id" "managed_policy" { byte_length = 4 } @@ -9,16 +9,8 @@ module "multi_runner" { aws_region = "eu-west-1" prefix = "computed-inputs" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/generated-${random_id.managed_policy.hex}" - 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" diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 5503c36b82..e2ff1eaa91 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -17,14 +17,6 @@ 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" @@ -36,6 +28,17 @@ variables { run "empty_runner_configurations_return_empty_output_maps" { command = plan + variables { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + + github_app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + 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." @@ -55,6 +58,15 @@ run "stable_v1_keeps_legacy_runner_module" { command = plan variables { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + + github_app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + tags = { StableGlobal = "global" Precedence = "global" @@ -724,6 +736,58 @@ run "stable_v1_keeps_legacy_runner_module" { } } +run "stable_v1_requires_flat_github_app" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "stable_v1_rejects_incomplete_flat_github_app" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + + github_app = { + id = "123456" + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "stable_v1_requires_flat_network_inputs" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + github_app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + + expect_failures = [terraform_data.validate_experimental] +} + run "experimental_v2_routes_through_provider_stack" { command = plan @@ -1347,7 +1411,7 @@ run "experimental_v2_routes_through_provider_stack" { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps - && local.translated_experimental.github.app == var.github_app + && var.github_app == null && local.translated_experimental.github.additional_apps == var.additional_github_apps && length(local.github_app_parameters.id) == 2 && length(module.ssm.additional_app_parameters) == 1 @@ -2574,6 +2638,10 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { command = plan variables { + github_app = { + id = "flat-app-id" + } + experimental = { github = { app = { @@ -2625,7 +2693,7 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { assert { condition = ( - var.github_app.id == "123456" + var.github_app.id == "flat-app-id" && local.translated_experimental.github.app.id == "different-app-id" && length(local.github_app_parameters.id) == 1 && output.ssm_parameters.id.name == "/github-action-runners/github-actions/app/github_app_id" diff --git a/modules/multi-runner/validations.experimental.tf b/modules/multi-runner/validations.experimental.tf index 5e7534f382..3c7c6c86d4 100644 --- a/modules/multi-runner/validations.experimental.tf +++ b/modules/multi-runner/validations.experimental.tf @@ -1,5 +1,31 @@ resource "terraform_data" "validate_experimental" { lifecycle { + precondition { + condition = local.use_multi_runner_config_v2 || var.github_app != null + error_message = "github_app is required when experimental.multi_runner_config is empty." + } + + precondition { + condition = local.use_multi_runner_config_v2 ? true : ( + var.github_app == null ? true : ( + (try(var.github_app.key_base64, null) != null || try(var.github_app.key_base64_ssm, null) != null) && + (try(var.github_app.id, null) != null || try(var.github_app.id_ssm, null) != null) && + (try(var.github_app.webhook_secret, null) != null || try(var.github_app.webhook_secret_ssm, null) != null) + ) + ) + error_message = "github_app must set one value from each pair: key_base64 or key_base64_ssm, id or id_ssm, and webhook_secret or webhook_secret_ssm." + } + + precondition { + condition = local.use_multi_runner_config_v2 || var.vpc_id != null + error_message = "vpc_id is required when experimental.multi_runner_config is empty." + } + + precondition { + condition = local.use_multi_runner_config_v2 || var.subnet_ids != null + error_message = "subnet_ids is required when experimental.multi_runner_config is empty." + } + precondition { condition = ( length(var.experimental.multi_runner_config) == 0 || diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 97f9785d45..09855ed736 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -23,19 +23,9 @@ variable "github_app" { name = string })) }) - - validation { - condition = (var.github_app.key_base64 != null || var.github_app.key_base64_ssm != null) && (var.github_app.id != null || var.github_app.id_ssm != null) && (var.github_app.webhook_secret != null || var.github_app.webhook_secret_ssm != null) - error_message = < Date: Sat, 15 Aug 2026 11:38:15 +0200 Subject: [PATCH 21/49] docs(multi-runner): document optional flat inputs --- modules/multi-runner/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 307fbc8e96..2eacfdab92 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -218,7 +218,7 @@ module "multi-runner" { | [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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`.
- `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive.
- `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK.
- `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].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.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(number, null)
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

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

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), 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({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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 | +| [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
}))
})
| `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\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`environment_variables`: Additional environment variables to merge into the Lambda configuration.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no | @@ -267,13 +267,13 @@ module "multi-runner" { | [scale\_up\_lambda\_memory\_size](#input\_scale\_up\_lambda\_memory\_size) | Memory size limit in MB for scale\_up lambda. | `number` | `512` | no | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = optional(string, "github-action-runners")
app = optional(string, "app")
runners = optional(string, "runners")
webhook = optional(string, "webhook")
})
| `{}` | no | | [state\_event\_rule\_binaries\_syncer](#input\_state\_event\_rule\_binaries\_syncer) | Option to disable EventBridge Lambda trigger for the binary syncer, useful to stop automatic updates of binary distribution | `string` | `"ENABLED"` | no | -| [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 | +| [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)` | `null` | no | | [syncer\_lambda\_s3\_key](#input\_syncer\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | | [syncer\_lambda\_s3\_object\_version](#input\_syncer\_lambda\_s3\_object\_version) | S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | | [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | | [user\_agent](#input\_user\_agent) | User agent used for API calls by lambda functions. | `string` | `"github-aws-runners"` | no | -| [vpc\_id](#input\_vpc\_id) | The VPC for security groups of the action runners. | `string` | n/a | yes | +| [vpc\_id](#input\_vpc\_id) | The VPC for security groups of the action runners. | `string` | `null` | no | | [webhook\_lambda\_apigateway\_access\_log\_settings](#input\_webhook\_lambda\_apigateway\_access\_log\_settings) | Access log settings for webhook API gateway. |
object({
destination_arn = string
format = string
})
| `null` | no | | [webhook\_lambda\_memory\_size](#input\_webhook\_lambda\_memory\_size) | Memory size limit in MB for webhook lambda. | `number` | `256` | no | | [webhook\_lambda\_s3\_key](#input\_webhook\_lambda\_s3\_key) | S3 key for webhook lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | From e9f89f7c01e668d50dc42fc92152b0d94eac5197 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 15:23:59 +0200 Subject: [PATCH 22/49] refactor(multi-runner): add orchestration provider boundary --- .github/workflows/terraform.yml | 21 +- ...-runner-orchestration-provider-boundary.md | 305 ++ docs/index.md | 18 +- .../internal/compute-provider-refactor.md | 321 ++- mkdocs.yaml | 2 + modules/compute-providers/ec2/README.md | 32 +- .../compute-providers/ec2/control-plane.tf | 2 +- .../compute-providers/ec2/instance-profile.tf | 2 +- modules/compute-providers/ec2/outputs.tf | 8 +- .../compute-providers/ec2/policies-runner.tf | 2 +- .../ec2/trust-policy/README.md | 12 +- modules/compute-providers/ec2/variables.tf | 12 +- modules/multi-runner/README.md | 57 +- .../config.experimental.translation.tf | 424 +-- modules/multi-runner/outputs.tf | 19 +- modules/multi-runner/queues.tf | 73 +- modules/multi-runner/runners.experimental.tf | 38 +- modules/multi-runner/runners.tf | 67 +- .../tests/computed-runner-inputs.tftest.hcl | 6 +- .../fixtures/computed-runner-inputs/README.md | 12 +- .../fixtures/computed-runner-inputs/main.tf | 76 +- .../tests/provider-routing.tftest.hcl | 2465 ++++++++++++----- .../multi-runner/validations.experimental.tf | 145 +- .../multi-runner/variables.experimental.tf | 778 +++--- modules/multi-runner/webhook.tf | 31 +- .../orchestration-providers/webhook/README.md | 56 + .../webhook/job-retry.tf | 48 + .../webhook}/job-retry/README.md | 14 +- .../webhook}/job-retry/iam-policies.tf | 40 +- .../webhook}/job-retry/job-retry.tf | 2 +- .../webhook}/job-retry/outputs.tf | 0 .../job-retry/tests/job-retry.tftest.hcl | 46 +- .../webhook}/job-retry/variables.tf | 4 +- .../webhook}/job-retry/versions.tf | 0 .../orchestration-providers/webhook/main.tf | 62 + .../webhook/outputs.tf | 22 + .../orchestration-providers/webhook/pool.tf | 65 + .../webhook/pool/README.md | 64 + .../webhook}/pool/iam-policies.tf | 28 +- .../webhook}/pool/outputs.tf | 0 .../webhook}/pool/pool.tf | 2 +- .../webhook}/pool/tests/provider.tftest.hcl | 73 +- .../webhook}/pool/variables.tf | 8 +- .../webhook}/pool/versions.tf | 0 .../webhook}/scale-down-state-diagram.md | 0 .../webhook/scale-runners.tf | 61 + .../webhook/scale-runners/README.md | 77 + .../webhook}/scale-runners/common-config.tf | 0 .../scale-runners/lambda-iam-policies.tf | 1 + .../webhook}/scale-runners/outputs.tf | 0 .../scale-runners/scale-down-iam-policies.tf | 19 +- .../webhook}/scale-runners/scale-down.tf | 0 .../scale-runners/scale-up-iam-policies.tf | 46 +- .../webhook}/scale-runners/scale-up.tf | 0 .../tests/scale-runners.tftest.hcl | 89 +- .../webhook}/scale-runners/variables.tf | 8 +- .../webhook}/scale-runners/versions.tf | 0 .../webhook/tests/webhook.tftest.hcl | 278 ++ .../webhook/variables.tf | 222 ++ .../webhook}/versions.tf | 0 modules/runner-config/README.md | 130 + modules/runner-config/common-config.tf | 54 + .../compute-provider.tf | 0 .../{runner-stack => runner-config}/ec2.tf | 0 .../runner-config/orchestration-provider.tf | 87 + modules/runner-config/outputs.tf | 40 + .../runner-role.tf | 2 +- .../runner-ssm-parameters.tf | 0 .../ssm-housekeeper.tf | 7 +- .../runner-config/ssm-housekeeper/README.md | 57 + .../ssm-housekeeper/iam-policies.tf | 1 + .../ssm-housekeeper/outputs.tf | 0 .../ssm-housekeeper/ssm-housekeeper.tf | 0 .../tests/ssm-housekeeper.tftest.hcl | 15 +- .../ssm-housekeeper/variables.tf | 2 +- .../ssm-housekeeper}/versions.tf | 0 .../tests/README.md | 0 .../tests/computed-iam-inputs.tftest.hcl | 10 + .../fixtures/computed-iam-inputs/README.md | 12 +- .../computed-iam-inputs.tf | 105 +- .../fixtures/computed-iam-inputs/versions.tf | 0 .../tests/pool.tftest.hcl | 289 +- .../tests/tags.tftest.hcl | 144 +- .../variables.compute-provider.tf | 4 +- .../variables.orchestration-provider.tf | 121 + .../variables.tf | 200 +- modules/runner-config/versions.tf | 10 + modules/runner-stack/README.md | 131 - modules/runner-stack/common-config.tf | 49 - modules/runner-stack/job-retry.tf | 62 - modules/runner-stack/outputs.tf | 28 - modules/runner-stack/pool.tf | 65 - modules/runner-stack/pool/README.md | 64 - modules/runner-stack/scale-runners.tf | 87 - modules/runner-stack/scale-runners/README.md | 77 - .../runner-stack/ssm-housekeeper/README.md | 57 - 96 files changed, 5496 insertions(+), 2677 deletions(-) create mode 100644 docs/adr/002-runner-orchestration-provider-boundary.md create mode 100644 modules/orchestration-providers/webhook/README.md create mode 100644 modules/orchestration-providers/webhook/job-retry.tf rename modules/{runner-stack => orchestration-providers/webhook}/job-retry/README.md (60%) rename modules/{runner-stack => orchestration-providers/webhook}/job-retry/iam-policies.tf (65%) rename modules/{runner-stack => orchestration-providers/webhook}/job-retry/job-retry.tf (99%) rename modules/{runner-stack => orchestration-providers/webhook}/job-retry/outputs.tf (100%) rename modules/{runner-stack => orchestration-providers/webhook}/job-retry/tests/job-retry.tftest.hcl (82%) rename modules/{runner-stack => orchestration-providers/webhook}/job-retry/variables.tf (97%) rename modules/{runner-stack => orchestration-providers/webhook}/job-retry/versions.tf (100%) create mode 100644 modules/orchestration-providers/webhook/main.tf create mode 100644 modules/orchestration-providers/webhook/outputs.tf create mode 100644 modules/orchestration-providers/webhook/pool.tf create mode 100644 modules/orchestration-providers/webhook/pool/README.md rename modules/{runner-stack => orchestration-providers/webhook}/pool/iam-policies.tf (61%) rename modules/{runner-stack => orchestration-providers/webhook}/pool/outputs.tf (100%) rename modules/{runner-stack => orchestration-providers/webhook}/pool/pool.tf (99%) rename modules/{runner-stack => orchestration-providers/webhook}/pool/tests/provider.tftest.hcl (70%) rename modules/{runner-stack => orchestration-providers/webhook}/pool/variables.tf (95%) rename modules/{runner-stack => orchestration-providers/webhook}/pool/versions.tf (100%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-down-state-diagram.md (100%) create mode 100644 modules/orchestration-providers/webhook/scale-runners.tf create mode 100644 modules/orchestration-providers/webhook/scale-runners/README.md rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/common-config.tf (100%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/lambda-iam-policies.tf (90%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/outputs.tf (100%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/scale-down-iam-policies.tf (68%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/scale-down.tf (100%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/scale-up-iam-policies.tf (55%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/scale-up.tf (100%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/tests/scale-runners.tftest.hcl (77%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/variables.tf (95%) rename modules/{runner-stack => orchestration-providers/webhook}/scale-runners/versions.tf (100%) create mode 100644 modules/orchestration-providers/webhook/tests/webhook.tftest.hcl create mode 100644 modules/orchestration-providers/webhook/variables.tf rename modules/{runner-stack/ssm-housekeeper => orchestration-providers/webhook}/versions.tf (100%) create mode 100644 modules/runner-config/README.md create mode 100644 modules/runner-config/common-config.tf rename modules/{runner-stack => runner-config}/compute-provider.tf (100%) rename modules/{runner-stack => runner-config}/ec2.tf (100%) create mode 100644 modules/runner-config/orchestration-provider.tf create mode 100644 modules/runner-config/outputs.tf rename modules/{runner-stack => runner-config}/runner-role.tf (95%) rename modules/{runner-stack => runner-config}/runner-ssm-parameters.tf (100%) rename modules/{runner-stack => runner-config}/ssm-housekeeper.tf (90%) create mode 100644 modules/runner-config/ssm-housekeeper/README.md rename modules/{runner-stack => runner-config}/ssm-housekeeper/iam-policies.tf (94%) rename modules/{runner-stack => runner-config}/ssm-housekeeper/outputs.tf (100%) rename modules/{runner-stack => runner-config}/ssm-housekeeper/ssm-housekeeper.tf (100%) rename modules/{runner-stack => runner-config}/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl (93%) rename modules/{runner-stack => runner-config}/ssm-housekeeper/variables.tf (99%) rename modules/{runner-stack => runner-config/ssm-housekeeper}/versions.tf (100%) rename modules/{runner-stack => runner-config}/tests/README.md (100%) rename modules/{runner-stack => runner-config}/tests/computed-iam-inputs.tftest.hcl (68%) rename modules/{runner-stack => runner-config}/tests/fixtures/computed-iam-inputs/README.md (90%) rename modules/{runner-stack => runner-config}/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf (68%) rename modules/{runner-stack => runner-config}/tests/fixtures/computed-iam-inputs/versions.tf (100%) rename modules/{runner-stack => runner-config}/tests/pool.tftest.hcl (53%) rename modules/{runner-stack => runner-config}/tests/tags.tftest.hcl (67%) rename modules/{runner-stack => runner-config}/variables.compute-provider.tf (99%) create mode 100644 modules/runner-config/variables.orchestration-provider.tf rename modules/{runner-stack => runner-config}/variables.tf (58%) create mode 100644 modules/runner-config/versions.tf delete mode 100644 modules/runner-stack/README.md delete mode 100644 modules/runner-stack/common-config.tf delete mode 100644 modules/runner-stack/job-retry.tf delete mode 100644 modules/runner-stack/outputs.tf delete mode 100644 modules/runner-stack/pool.tf delete mode 100644 modules/runner-stack/pool/README.md delete mode 100644 modules/runner-stack/scale-runners.tf delete mode 100644 modules/runner-stack/scale-runners/README.md delete mode 100644 modules/runner-stack/ssm-housekeeper/README.md diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 4a6def3312..ae858db134 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -88,10 +88,12 @@ jobs: "compute-providers/ec2", "compute-providers/ec2/trust-policy", "runner-binaries-syncer", - "runner-stack", - "runner-stack/job-retry", - "runner-stack/scale-runners", - "runner-stack/ssm-housekeeper", + "orchestration-providers/webhook", + "orchestration-providers/webhook/job-retry", + "orchestration-providers/webhook/pool", + "orchestration-providers/webhook/scale-runners", + "runner-config", + "runner-config/ssm-housekeeper", "runners", "setup-iam-permissions", "ssm", @@ -221,11 +223,12 @@ jobs: module: - 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/orchestration-providers/webhook + - modules/orchestration-providers/webhook/job-retry + - modules/orchestration-providers/webhook/pool + - modules/orchestration-providers/webhook/scale-runners + - modules/runner-config + - modules/runner-config/ssm-housekeeper - modules/compute-providers/ec2 - modules/compute-providers/ec2/trust-policy defaults: diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md new file mode 100644 index 0000000000..dbf6310827 --- /dev/null +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -0,0 +1,305 @@ +# ADR-002: Runner Orchestration Provider Boundary + +## Status + +Proposed + +## Date + +2026-08-15 + +## Context + +The multi-runner module currently receives workflow-job demand through a shared GitHub webhook. A build queue then invokes scale-up, while scheduled Lambda functions handle scale-down, a runner pool, and queued-job retries. Those components evolved together and their settings are spread across shared module inputs and each runner entry. + +That layout assumes every runner configuration uses the same demand-control model. It also makes the runner configuration module responsible for webhook-specific resources. Adding another model would require provider conditionals throughout the module or a second copy of the common runner and compute-provider wiring. + +GitHub Actions Runner Scale Sets require a different control model. A future implementation is expected to use the runner scale-set and agent APIs, including: + +- `_apis/runtime/runnerscalesets` +- `_apis/distributedtask/pools/0/agents` + +Unlike the current event and schedule driven Lambda components, a scale-set controller maintains reconciliation state and long-lived coordination with GitHub. It may therefore need a containerized service, with ECS as a candidate deployment target, rather than another independent Lambda handler. + +The Terraform contract should make that future addition possible without moving webhook fields a second time. This ADR defines that boundary. It does not implement the scale-set API client, controller, container image, or ECS resources. + +## Terminology + +- **Runner configuration**: One entry in `experimental.multi_runner_config`, including common runner behavior, one orchestration provider, and one compute provider. +- **Orchestration provider**: The implementation that receives or reconciles runner demand and owns the control components needed to turn that demand into capacity actions. +- **Compute provider**: The implementation that creates and manages runner capacity, such as EC2. It supplies capabilities to the selected orchestration provider. +- **Webhook orchestration**: The existing webhook, queue, scale-up, scale-down, pool, and job-retry implementation. +- **Scale-set orchestration**: A future stateful controller built on GitHub's runner scale-set APIs. + +The public contract and documentation use “runner configuration.” They do not introduce a separate nickname for the existing implementation. + +## Decision + +We will introduce a typed orchestration-provider boundary in the experimental multi-runner v2 interface. + +### Provider selection is per runner configuration + +Every experimental runner configuration must contain an `orchestration` object with exactly one non-null typed provider block. In this phase the only supported block is `webhook`: + +```hcl +experimental = { + multi_runner_config = { + linux_arm64 = { + orchestration = { + webhook = { + runner = { + maximum_count = 4 + } + + github = { + organization_runners = true + } + + matcherConfig = { + labelMatchers = [["linux", "arm64"]] + } + } + } + + compute_provider = { + ec2 = { + instance_types = ["m7g.large"] + } + } + } + } +} +``` + +Selection is based on the populated provider block, not on a string discriminator. The wrapper's nullness must be known during planning because it determines the Terraform graph. Values inside the selected provider may remain unknown until apply. + +Validation counts non-null provider blocks rather than naming one special case. A future provider can therefore be added as a sibling without changing the selection rule. Different runner configurations may select different providers once more than one exists, but one runner configuration cannot combine providers. + +### Global orchestration blocks provide defaults; they do not select providers + +`experimental.orchestration.webhook` is the global defaults and shared-component namespace for webhook orchestration. Its presence does not select webhook orchestration for every runner configuration. Selection remains under `experimental.multi_runner_config..orchestration`. + +The webhook global namespace owns: + +- the default maximum runner count enforced by webhook scale-up and pool components; +- queue-selection strategy, EventBridge routing, and matcher-parameter tier; +- build-queue defaults, redrive behavior, tags, and encryption; +- the shared webhook Lambda configuration and artifact selection; +- the scale-control artifact selection; and +- default scale-up, scale-down, and pool component settings. + +Job-retry remains a per-runner-configuration webhook setting in this phase; its +typed block supplies its own defaults rather than inheriting a global block. + +The maximum runner count is likewise provider-owned rather than common runner identity. Its canonical paths are `experimental.orchestration.webhook.runner.maximum_count` for the global default and `experimental.multi_runner_config..orchestration.webhook.runner.maximum_count` for a runner-configuration override. Stable-v1 translation maps the existing required `runners_maximum_count` into the latter path, and the unchanged stable `modules/runners` call reads it from that canonical provider block. No `runner.maximum_count` compatibility alias is retained in the experimental object. + +The common `experimental.github` block continues to own GitHub API client settings shared across implementations, including `enterprise_server` and `user_agent`. Webhook-specific GitHub settings are limited to fields such as `organization_runners`. + +The common `experimental.lambda` block contains only provider-neutral Lambda substrate: runtime, architecture, networking, role settings, additional principals, tags, and an optional shared artifact bucket. It does not select a provider archive. Each component owner supplies its own local zip or S3 object key and version. + +The provider-neutral SSM housekeeper owns its artifact selector under `ssm.housekeeper.lambda.artifact`. A runner-configuration selection overrides the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the common Lambda artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. This selector is independent of the webhook scale artifact. Stable-v1 translation maps the existing runner artifact into both canonical component contracts so the translated representation remains complete without changing the stable resource path. + +For a selected webhook provider, resolution follows: + +```text +runner-configuration override > experimental orchestration.webhook default +``` + +Tag maps merge from broad to narrow. A runner-configuration override affects only that runner configuration; it does not configure a shared singleton. + +### Module ownership follows the provider boundary + +The Terraform implementation is split as follows: + +| Layer | Responsibility | +| --- | --- | +| `modules/multi-runner` | Selects stable or experimental mode, resolves global and runner-configuration values, owns shared webhook ingress and build queues, and routes typed provider objects. | +| `modules/runner-config` | Composes provider-neutral runner resources, selects exactly one orchestration provider and one compute provider, creates or selects the runner role, and connects provider capabilities. | +| `modules/orchestration-providers/webhook` | Owns webhook orchestration composition, provider defaults, tag layering, and the scale, pool, and retry leaf modules. | +| `modules/orchestration-providers/webhook/scale-runners` | Owns the scale-up and scale-down Lambdas, schedules, queue integration, IAM, and outputs. | +| `modules/orchestration-providers/webhook/pool` | Owns optional scheduled pool resources and IAM. | +| `modules/orchestration-providers/webhook/job-retry` | Owns optional queued-job retry resources and IAM. | +| `modules/runner-config/ssm-housekeeper` | Owns provider-neutral cleanup of runner token and configuration parameters, including its component-specific Lambda artifact. | +| `modules/compute-providers/` | Owns capacity resources and returns policy, environment-variable, trust-policy, and resource capabilities. | + +The former `modules/runner-stack` name becomes `modules/runner-config`. “Runner configuration” describes the module's purpose without implying a specific deployment topology. + +```mermaid +flowchart TD + Multi["multi-runner: normalize and route"] --> Config["runner-config: compose one runner configuration"] + Config --> Selector{"exactly one orchestration provider"} + Selector --> Webhook["orchestration-providers/webhook"] + Selector -. future .-> ScaleSet["orchestration-providers/scale-set"] + Config --> ComputeSelector{"exactly one compute provider"} + ComputeSelector --> EC2["compute-providers/ec2"] + EC2 --> Capabilities["compute capabilities"] + Capabilities --> Webhook + Capabilities -. future .-> ScaleSet + Webhook --> Scale["scale-runners"] + Webhook --> Pool["pool"] + Webhook --> Retry["job-retry"] +``` + +Provider leaf modules live below `modules/orchestration-providers/webhook`, not below `modules/runner-config`. This keeps the composition module small and prevents provider-owned resources from becoming a permanent part of the common contract. + +### Compute providers expose capabilities, not orchestration resources + +The selected compute provider remains independent from the selected orchestration provider. It returns the policy documents, environment variables, trust policy, managed-policy references, and resources needed by orchestration components. + +`runner-config` adapts that provider output into the scale-up, scale-down, and pool capabilities consumed by webhook orchestration. The webhook provider owns its Lambda roles and attaches the capability fragments it needs. The compute provider does not create the common runner role or webhook resources. + +This direction keeps the dependency graph one-way: + +```text +runner-config -> compute provider -> capability contract -> orchestration provider +``` + +A future scale-set controller may require a different subset or extension of the capability contract. That extension belongs at the provider boundary; it must not add scale-set conditionals to the webhook leaves. + +### Compatibility and state are explicit + +Stable inputs are translated into the same internal canonical representation so defaults and shared singleton values have one resolution path. Stable runner configurations continue to call the existing `modules/runners` implementation at their existing addresses. Opting into experimental v2 is module-wide: a non-empty `experimental.multi_runner_config` replaces, rather than merges with, the stable map. + +The experimental implementation preserves in-progress v2 state with declarative moves: + +- `module.runner_stacks` moves to `module.runner_configs`; +- scale-up/scale-down resources move beneath `module.webhook["webhook"].module.scale_runners`; +- pool resources move beneath `module.webhook["webhook"].module.pool`; and +- job-retry resources move beneath `module.webhook["webhook"].module.job_retry`. + +The canonical v2 output groups resources under `orchestration.webhook`. Direct `scale_up`, `scale_down`, and `pool` outputs remain compatibility aliases during the experimental transition. + +This ADR does not define an automatic stable-v1-to-v2 state migration. Existing deployments remain on the stable path until that migration is separately designed and documented. + +### IAM and encryption follow resource ownership + +Provider-owned IAM policies use conditional statements for optional KMS keys. A null key omits the statement; policies do not use placeholder account IDs, key IDs, or ARNs to satisfy Terraform typing. + +Parameter Store and queue encryption are separate concerns: + +| Key purpose | Consumer | Required KMS actions | +| --- | --- | --- | +| GitHub App parameters in Parameter Store | Scale-up, scale-down, pool, and job retry as applicable | `kms:Decrypt` | +| Encrypted build queue | Scale-up | `kms:Decrypt` | +| Encrypted build queue | Job retry when publishing a retry | `kms:Decrypt`, `kms:GenerateDataKey` | + +Because queue KMS values are used as IAM `Resource` entries, the experimental queue contract requires a KMS key ARN when a customer-managed key is selected. SSM write access is scoped to the runner token and configuration paths. Wildcard resources are allowed only for AWS APIs that do not support resource-level permissions, such as the required X-Ray actions, and the policy must document that reason. + +### Existing shared modules stay unchanged + +This refactor does not change `modules/webhook` or `modules/ssm`. + +The shared webhook remains at its existing unconditional module address. The shared SSM module continues to create or reference the webhook secret even when no runner configuration selects webhook orchestration. That singleton contract may support other uses and is independent of the per-runner exact-one provider selection. + +Any later proposal to make those shared modules conditional is a separate compatibility and state decision. + +### Scale-set implementation is deferred + +No `scale_set` field is added to the Terraform type in this phase. The typed object and module layout reserve the extension point without publishing an incomplete contract. + +A follow-up design must decide at least: + +1. the public TypeScript SDK surface for runner scale-set and agent operations; +2. authentication, API-version negotiation, error mapping, retries, and idempotency; +3. the reconciliation and persistence model for desired, acquired, busy, and removed runners; +4. the controller's shutdown, recovery, concurrency, and high-availability behavior; +5. the container build and release contract for the TypeScript service; +6. whether ECS/Fargate is the default deployment and how networking, scaling, logging, health checks, and upgrades work; +7. the capabilities required from each compute provider; and +8. Terraform migration and coexistence behavior when the new provider is enabled. + +The intended end state permits webhook and scale-set orchestration in the same multi-runner module instance when different runner configurations select them. It does not permit both controllers to own the same runner configuration. + +## Consequences + +### Positive + +- A future orchestration provider becomes a sibling module instead of a cross-cutting conditional. +- Runner and compute-provider configuration remains reusable across demand-control models. +- Provider-owned queue, Lambda, artifact, IAM, and output settings have one discoverable namespace. +- Exact-one validation prevents ambiguous ownership of a runner configuration. +- Stable behavior and shared singleton addresses remain unchanged. +- Moved blocks preserve the addresses already created by the experimental v2 work. + +### Negative + +- The experimental input is more deeply nested than the existing flat interface. +- Global webhook defaults and per-runner webhook selection use similarly named blocks with different purposes. +- Internal modules have explicit adapter objects and capability contracts that require maintenance. +- Adding a stateful provider will still require new runtime, deployment, observability, and failure-recovery design; the Terraform boundary alone does not solve those concerns. +- Compatibility aliases temporarily expose both canonical and historical v2 output paths. + +## Alternatives Considered + +### Add a flat orchestration mode string + +A value such as `orchestration_type = "webhook"` plus a flat collection of settings would make unrelated fields valid for every provider and require cross-field validation. + +**Decision**: Use typed, nullable sibling blocks. The populated block both selects and configures the provider. + +### Put provider conditionals directly in `runner-config` + +This would keep fewer directories initially, but every provider would add resources, variables, IAM branches, and outputs to the common module. + +**Decision**: Keep `runner-config` as a selector and composer. Put concrete resources under `modules/orchestration-providers/`. + +### Keep webhook leaves under `runner-config` + +Scale, pool, and retry are all webhook orchestration behavior. Leaving them under the common module would blur ownership and make a future provider appear to support components it does not use. + +**Decision**: Move the leaves under the webhook provider root and preserve state with moved blocks. + +### Add the scale-set schema and ECS service now + +Publishing placeholders would lock in names and types before the API client, reconciliation semantics, and runtime model have been validated. + +**Decision**: Publish only the provider-neutral extension point now. Add the scale-set provider in a follow-up ADR and implementation. + +### Make the shared webhook and webhook secret conditional + +That change would alter existing singleton resource addresses and would conflate module-level ingress with per-runner provider selection. + +**Decision**: Leave `modules/webhook` and `modules/ssm` unchanged in this refactor. + +## Migration and Verification + +Implementation and review must verify the boundary at several levels. + +### Terraform contract tests + +- A runner configuration with exactly one webhook provider plans successfully. +- Zero or multiple non-null orchestration providers fail with a focused validation message. +- Provider-wrapper nullness may shape the plan while values inside the selected provider may be unknown until apply. +- Per-runner values override global webhook defaults, and omitted nullable values inherit them. +- Shared singleton resources consume global values rather than arbitrary per-runner overrides. +- Stable inputs preserve stable resource addresses and output shape. +- The experimental module and child-module renames produce move operations rather than destroy/create operations. +- Canonical nested outputs and compatibility aliases reference the same resources. + +### Provider and IAM tests + +- `runner-config` routes only the selected orchestration provider. +- The webhook root composes scale, pool, and retry leaves with the resolved values supplied by `multi-runner`; it does not invent fallback ARNs or empty resource objects. +- Compute-provider capability fragments reach the correct webhook component. +- Null SSM or queue KMS keys omit their IAM statements. +- Queue and Parameter Store KMS permissions remain separate and use the least actions required. +- SSM writes are limited to the configured token and runner-configuration paths. +- Any wildcard IAM resource has an AWS API limitation documented next to it. + +### Compatibility checks + +- `modules/webhook` has no diff. +- `modules/ssm` has no diff. +- Stable multi-runner tests continue to pass. +- Experimental provider-routing, computed-input, runner-config, webhook-provider, scale, pool, retry, and SSM-housekeeper tests pass. +- Terraform formatting, documentation generation, and repository pre-commit checks are clean. + +Before an existing experimental deployment adopts the module rename, its plan must be inspected for only the expected moved addresses. Stable deployments must not enable v2 until a stable-to-v2 migration procedure exists. + +## References + +- [Experimental compute-provider refactor](../modules/internal/compute-provider-refactor.md) +- [GitHub Actions Runner Scale Set reference implementation](https://github.com/actions/scaleset) +- [PR #5204 warm-pool proposal and ADR structure](https://github.com/github-aws-runners/terraform-aws-github-runner/pull/5204) +- [ADR-001 warm-pool decision in PR #5204](https://github.com/github-aws-runners/terraform-aws-github-runner/blob/feature/warm-pool-hibernation/docs/adr/001-warm-pool-hibernation.md) +- [ADR-001 warm-pool implementation plan in PR #5204](https://github.com/github-aws-runners/terraform-aws-github-runner/blob/feature/warm-pool-hibernation/docs/adr/001-warm-pool-implementation-plan.md) diff --git a/docs/index.md b/docs/index.md index 0ab566e66e..00d14dc12c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,23 +101,23 @@ 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 top-level `multi_runner_config` entries continue to use the unchanged `runners` module when `experimental.multi_runner_config` is empty. A non-empty experimental map takes priority over the stable map; the maps are not combined. Experimental entries use the new provider-oriented `runner-stack`. +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 top-level `multi_runner_config` entries continue to use the unchanged `runners` module when `experimental.multi_runner_config` is empty. A non-empty experimental map takes priority over the stable map; the maps are not combined. Experimental entries use the new provider-oriented `runner-config`. -Multi-runner centralizes mode selection and canonical configuration in `config.experimental.translation.tf`. A non-empty experimental lane map selects v2; otherwise the file projects the flat globals and stable lanes into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base` by applying schema defaults, global/lane precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults. Provider selection and the shared runner-binary syncer and discovery use this plan-known base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-stack GitHub client settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume that final canonical representation. Stable lanes are adapted back into the existing `module.runners["configuration"]` call, preserving its Terraform addresses while removing a second configuration path. The `module.runner_stacks` call directly iterates the gated final lanes, inlines the environment tag and live GitHub App and build-queue references, exposes the scale-up, scale-down, and pool aliases expected by runner-stack, and forwards the remaining canonical objects, including the typed `compute_provider = { ec2 = ... }` wrapper. +Multi-runner centralizes mode selection and canonical configuration in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects the flat globals and stable runner configurations into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base` by applying schema defaults, global/runner-configuration precedence, tag merges, IAM ownership, paths, observability, webhook queues, and provider defaults. Provider selection and the shared runner-binary syncer and discovery use this plan-known base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, webhook queue event mapping, Lambda artifact and principals, the webhook pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, webhook queues, and runner implementations consume that final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, preserving their Terraform addresses while removing a second configuration path. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references into `orchestration.webhook`, and forwards the remaining canonical objects, including the typed orchestration and compute-provider wrappers. -The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `webhook`, `lambda` (including nested `scale_up`, `scale_down`, `webhook`, and `pool` settings), `queue`, `ssm`, `observability`, and `compute_provider`. These globals configure v2 runner stacks and the applicable singleton shared components. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides remain lane-only. A nullable lane field with a corresponding experimental global inherits that global value when omitted or null. A lane that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. +The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider`. Root `experimental.lambda` is provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults used across orchestration and non-orchestration consumers. Webhook-specific global defaults live together under `experimental.orchestration.webhook`, including the maximum runner count, the shared webhook's routing and matcher storage, build-queue defaults and encryption, control-plane artifact selectors, and webhook, scale-up, scale-down, and pool Lambda settings. This global block supplies defaults; it does not select an orchestration provider. Each runner configuration separately makes that selection through its own `orchestration` wrapper. The only supported orchestration provider today is `orchestration.webhook`, which owns that runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up and scale-down settings, scheduled pool, and job retry. Keeping those fields behind a typed provider wrapper allows future orchestration providers to be introduced as mutually exclusive siblings without moving the common runner, Lambda substrate, SSM, observability, or compute-provider contracts again. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides remain configuration-specific. A nullable runner-configuration field with a corresponding experimental global inherits that global value when omitted or null. A runner configuration that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. -Global `experimental.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Lane `experimental.multi_runner_config[].queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures only the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues, and its CMK is independent from `experimental.ssm.kms_key_id`. The current v2 webhook, scale-up, and job-retry IAM policies do not derive KMS grants from a distinct queue CMK, so callers must grant those roles the required key permissions. For v2, `experimental.multi_runner_config[].queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].lambda.scale_up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. +Global `experimental.orchestration.webhook.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Runner-configuration `experimental.multi_runner_config[].orchestration.webhook.queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue, and its CMK is independent from `experimental.ssm.kms_key_id`. Runner-config forwards the distinct build-queue key to the webhook orchestration provider: scale-up receives `kms:Decrypt`, while job-retry receives `kms:Decrypt` and `kms:GenerateDataKey` for publishing. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when that publisher targets customer-managed encrypted queues. For v2, `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner stacks consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-stack GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner configurations consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-config GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. Both settings belong inside `experimental.github`; they are not root `experimental` fields or per-configuration orchestration settings. -The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Runner-stack control-plane artifact selection is global under `experimental.lambda.scale.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. Root `experimental.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. +The shared webhook, runner configurations, SSM housekeepers, runner-binary syncer, termination watcher, and AMI housekeeper consume the provider-neutral runtime, architecture, networking, role, and tag defaults under `experimental.lambda`; `lambda.principals` configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global observability settings configure logging and tracing, and global metrics also configure the termination watcher. Webhook-orchestration control-plane artifact selection is global under `experimental.orchestration.webhook.lambda.scale.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. `experimental.orchestration.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `experimental.orchestration.webhook.lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. -`experimental.compute_provider.ec2.runner_binaries.enabled` defaults each EC2 lane to the shared synchronized distribution, while a nullable lane `compute_provider.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. +`experimental.compute_provider.ec2.runner_binaries.enabled` defaults each EC2 runner configuration to the shared synchronized distribution, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. -Global `ssm.paths.root` is the base for shared and lane-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the lane key only for lane-owned paths. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner stack; it does not select encryption for runtime-created lane runner parameters. IAM consumers keep a static policy shape with a harmless sentinel resource when the value is null, so a real ARN may remain unknown until apply. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than lane overrides. +Global `ssm.paths.root` is the base for shared and runner-configuration-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the runner-configuration key only for configuration-owned paths. The default derived base is `/github-action-runners/${prefix}`, and runner token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration; it does not select encryption for runtime-created runner parameters. Webhook-provider leaves conditionally omit their KMS statements when this value is null, while apply-time-unknown key ARNs remain valid during planning. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than per-configuration overrides. -Each lane still selects exactly one provider and supplies provider-specific required fields with its own `compute_provider` block. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider object reaches runner-stack, which also validates that exactly one typed block is populated before dispatch. The 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. Later releases will route the existing v1 translation through runner-stack, 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. +Each runner configuration selects two independent typed providers: one `orchestration` provider for demand control and one `compute_provider` for runner capacity. `orchestration.webhook` is the sole supported orchestration provider today. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider objects reach runner-config, which validates each exact-one selection and dispatches the matching root-level provider module. `orchestration-providers/webhook` owns scale-up, scale-down, scheduled pool, job retry, and their webhook-specific defaults and tag layering; runner-config retains common SSM housekeeping plus 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 modules are internal experimental implementation boundaries, not standalone public entry points. Later releases can add mutually exclusive orchestration siblings without changing the common or compute-provider contracts. 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 e1d3835675..2661c5b172 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -12,40 +12,43 @@ The refactor introduces a provider boundary so a future MicroVM or other backend ## Ownership model -The implementation is split into orchestration, provider-neutral control-plane components, and compute-provider implementations: +The implementation is split into demand-orchestration selection, provider-neutral control-plane components, and compute-provider implementations: | Layer | Owns | | --- | --- | -| `multi-runner` | Module-level v1/v2 mode selection, flat/nested input projection, canonical global/lane resolution, direct runner-stack input adaptation, configuration keys, build queues, webhook matching, and runner-binary discovery. | -| `runner-stack` | Typed 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. | +| `multi-runner` | Module-level v1/v2 mode selection, flat/nested input projection, canonical global/runner-configuration resolution, typed orchestration and compute-provider routing, configuration keys, webhook build queues and matching, and runner-binary discovery. | +| `runner-config` | Typed orchestration and compute-provider dispatch, shared runner configuration in SSM, the SSM housekeeper, and the common runner role and policy attachments. | +| `orchestration-providers/webhook` | Webhook-provider selection contract, defaults and tag layering, plus composition of the provider-owned control-plane leaves. | +| `orchestration-providers/webhook/scale-runners` | Provider-neutral scale-up and scale-down Lambdas, schedules and queue integration, and their execution roles and policies. | +| `orchestration-providers/webhook/pool` | Optional scheduled runner-pool resources and their Lambda and IAM wiring. | +| `orchestration-providers/webhook/job-retry` | Optional queued-job retry resources and their Lambda and IAM wiring. | +| `runner-config/ssm-housekeeper` | Parameter Store cleanup Lambda, schedule, logging, and IAM resources. | | `compute-providers//trust-policy` | Provider-specific default runner-role trust, merged with the optional caller-provided trust document before the common role is created. | | `compute-providers/` | Provider-specific resources, permission requirements, and the IAM and environment-variable fragments consumed by the common control plane after the runner role is resolved. | The EC2 provider 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 in this phase. -The modules below `runner-stack` are internal implementation boundaries, not standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config`; `multi-runner` calls `runner-stack`, which composes the internal modules. Their direct input and output contracts may change while v2 remains experimental. +Runner-config, the root orchestration and compute providers, and their leaf modules are internal implementation boundaries rather than standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config`; `multi-runner` calls `runner-config`, which selects the provider modules. Their direct input and output contracts may change while v2 remains experimental. -Each external v2 lane populates exactly one typed provider block, such as `experimental.multi_runner_config..compute_provider.ec2`; that block's presence must be known during planning because it determines routing. Multi-runner module validation enforces this selection rule through resource preconditions, while each compute-provider implementation owns its provider-specific semantic validation. +Each external v2 runner configuration selects demand orchestration separately from its compute provider. The required `orchestration` wrapper has one supported provider today: `experimental.multi_runner_config..orchestration.webhook`. It owns the runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up, scale-down, pool, and job-retry settings. The wrapper is intentionally typed as a provider boundary so later orchestration implementations can be added as mutually exclusive siblings without moving common configuration fields again. -After resolving global `experimental.compute_provider.ec2` values with the selected lane's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-stack`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-stack validates that exactly one provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It also passes the provider-neutral `runner`, `github`, `ssm`, and `observability` objects without expanding them back into prefixed scalar inputs. +The runner configuration also populates exactly one typed compute-provider block, such as `experimental.multi_runner_config..compute_provider.ec2`; that block's presence must be known during planning because it determines capacity routing. Multi-runner module validation enforces both selections through resource preconditions, while each provider implementation owns its provider-specific semantic validation. -Binary discovery is completed before the stack call. `config.experimental.translation.tf` enriches the final canonical lane at `compute_provider.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_stacks` call then passes that lane's wrapped `compute_provider` object unchanged. Runner-stack and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.binaries_syncer`. +After resolving global `experimental.compute_provider.ec2` values with the selected runner configuration's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-config`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-config validates that exactly one compute-provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It independently validates the exact-one `orchestration = { webhook = { ... } }` wrapper and invokes the selected root orchestration provider with provider-neutral common objects such as `runner`, the Lambda substrate, SSM, observability, and the selected compute-provider capabilities. -The common stack creates or selects the runner IAM role, but the current EC2 provider owns the role's default trust-policy document. Each provider implementation supplies a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. The common stack uses the isolated trust-policy output when it creates the runner role and attaches the full provider's permission documents to the roles owned by the corresponding common components. A provider never creates or attaches a common IAM role. +Binary discovery is completed before the runner-config call. `config.experimental.translation.tf` enriches the final canonical runner configuration at `compute_provider.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_configs` call then passes that runner configuration's wrapped `compute_provider` object unchanged. Runner-config and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.binaries_syncer`. + +Runner-config creates or selects the runner IAM role, but the current EC2 provider owns the role's default trust-policy document. Each provider implementation supplies a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. Runner-config uses the isolated trust-policy output when it creates the runner role, attaches runner policies itself, and passes the scale-up, scale-down, and pool capabilities to the selected orchestration provider. A provider never creates or attaches the common runner IAM role. The trust relationship is deliberately rendered by an isolated provider submodule: -1. `multi-runner` validates the lane's typed provider selection, resolves its global and lane values, and invokes `runner-stack` with the wrapped provider configuration. -2. `runner-stack` derives the selected provider from the single non-null typed block. +1. `multi-runner` validates the runner configuration's typed orchestration and compute-provider selections, resolves its global and per-configuration values, and invokes `runner-config` with both wrapped provider configurations. +2. `runner-config` independently derives the orchestration and compute providers from their single non-null typed blocks. 3. `compute-providers//trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input. -4. `runner-stack` creates or selects the common runner role from the returned `assume_role_policy`. +4. `runner-config` creates or selects the common runner role from the returned `assume_role_policy`. 5. The full compute provider receives the resolved role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements. 6. The provider returns its nested policy, environment-variable, and resource contract. -7. The common components attach the returned policies to the runner, scale-up, scale-down, and pool roles they own. +7. Runner-config attaches runner policies, while `orchestration-providers/webhook` attaches scale-up, scale-down, and pool policy fragments to the roles it owns through its leaves. The trust-policy output depends only on its input documents, not on the full provider resources that consume the runner role. This preserves provider ownership of the trust relationship while keeping the dependency graph one-way. @@ -53,11 +56,11 @@ The trust-policy output depends only on its input documents, not on the full pro Multi-runner produces one canonical consumer representation for both input modes: -1. `config.experimental.translation.tf` selects the module mode and builds `local.raw_translated_experimental`. A non-empty experimental lane map selects the nested `var.experimental` input for v2; otherwise the file projects flat module globals and stable `multi_runner_config` lanes into the same schema for v1. -2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/lane precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, queues, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. -3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and each enabled EC2 lane's `compute_provider.ec2.binaries_syncer.s3`. The remaining shared components, queues, and runner implementations consume this final canonical object. +1. `config.experimental.translation.tf` selects the module mode and builds `local.raw_translated_experimental`. A non-empty experimental runner-configuration map selects the nested `var.experimental` input for v2; otherwise the file projects flat module globals and stable `multi_runner_config` entries into the same schema for v1. +2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/runner-configuration precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, `orchestration.webhook`, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. +3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, webhook queue event mapping, Lambda artifact and principals, the webhook pool Lambda wrapper, SSM KMS, and each enabled EC2 runner configuration's `compute_provider.ec2.binaries_syncer.s3`. The remaining shared components, webhook queues, and runner implementations consume this final canonical object. -Stable lanes remain on `module.runners["configuration"]`: `runners.tf` adapts each final canonical lane back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate configuration source. This is not the phase-2 implementation migration to `runner-stack`. For v2, `module.runner_stacks` directly iterates the gated final lane map. Its input arguments inline the environment tag and live GitHub App and build-queue references, expose `scale_up`, `scale_down`, and `pool` as the sibling aliases expected by runner-stack, and forward the remaining canonical lane objects. Binary output enrichment and all other derived lane shaping are already complete in canonical translation. +Stable translation always emits `orchestration.webhook`, but stable runner configurations remain on `module.runners["configuration"]`: `runners.tf` adapts each final canonical runner configuration back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate configuration source. This is not the phase-2 implementation migration to `runner-config`. For v2, `module.runner_configs` directly iterates the gated final runner-configuration map. Its input arguments inline the environment tag and live GitHub App and build-queue references into `orchestration.webhook`, then forward the complete orchestration and compute-provider wrappers. Binary output enrichment and all other derived configuration shaping are already complete in canonical translation. ## Phase 1 dispatch and compatibility @@ -69,52 +72,55 @@ flowchart TD Experimental["var.experimental"] --> Select Select -->|No| V1["raw_translated_experimental: project flat v1"] Select -->|Yes| V2["raw_translated_experimental: select nested v2"] - V1 --> Base["translated_experimental_base: defaults and global/lane resolution"] + V1 --> Base["translated_experimental_base: defaults and global/configuration resolution"] V2 --> Base Base --> Discovery["Provider selection, runner-binary syncer, and discovery"] Discovery --> Final["translated_experimental: enrich EC2 binaries_syncer.s3"] Final --> Singleton["Shared SSM, webhook, termination watcher, and AMI housekeeper"] - Final --> Shared["Build queues and webhook matching"] + Final --> Shared["Webhook build queues and matching"] Final -->|v1 legacy-argument adapter| Legacy["module.runners[configuration]"] - Final -->|v2 direct module input adaptation| 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 --> Trust["compute-providers/provider/trust-policy"] + Final -->|v2 direct module input adaptation| RunnerConfig["module.runner_configs[configuration]"] + RunnerConfig --> Orchestration["orchestration-providers/webhook"] + Orchestration --> Scaling["orchestration-providers/webhook/scale-runners"] + Orchestration --> Pool["orchestration-providers/webhook/pool"] + Orchestration --> Retry["orchestration-providers/webhook/job-retry"] + RunnerConfig --> Housekeeper["runner-config/ssm-housekeeper"] + RunnerConfig --> Trust["compute-providers/provider/trust-policy"] Trust --> Role["common runner role"] Role --> Provider - Stack --> Provider["compute-providers/"] + RunnerConfig --> Provider["compute-providers/"] Provider --> Scaling Provider --> Pool ``` -The canonical object gives shared singleton resources one global representation and queues and runner implementations one fully resolved lane representation: +The canonical object gives shared singleton resources one global representation and each webhook orchestration and runner implementation one fully resolved runner-configuration representation: - When `experimental.multi_runner_config` is empty, every key in the stable top-level `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address. - Flat v1 inputs are projected into `raw_translated_experimental`, resolved into `translated_experimental_base`, finalized as `translated_experimental`, and then adapted by `runners.tf` to the existing child-module arguments. - The v1 translation uses `runners_scale_up_lambda_timeout` for build-queue visibility, preserving the stable flat behavior. +- The v1 translation wraps its existing registration scope, matcher, queue, scale, pool, and retry values under `orchestration.webhook`; the stable public input and resource behavior remain unchanged. - Stable queue tagging and the flat `runners_map` output remain unchanged. -- When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-stack` at `module.runner_stacks["configuration"]`; stable-map entries are not dispatched. +- When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-config` at `module.runner_configs["configuration"]`; stable-map entries are not dispatched. +- Declarative `moved` blocks preserve the experimental call rename from `module.runner_stacks` to `module.runner_configs` and move the former runner-config scale, pool, and retry children directly beneath `module.webhook["webhook"]` without an intermediate state address. - Experimental resources are exposed separately through the nested `runners_map_v2` output. - The maps are not combined. A non-empty v2 map has explicit priority over the stable map. -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. +No v1-to-v2 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 -Nested global settings are the source of defaults for v2 runner stacks and the applicable singleton shared components. The GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume translated globals. Migrated v2 consumers do not fall back to matching flat inputs; those values seed the stable-mode translation only. The singleton-specific webhook, binary-syncer, termination-watcher, and AMI-housekeeper settings all have nested owners. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Per-lane values override globals only inside that lane and never configure singleton resources. Nested defaults mirror established v1 behavior, while nullable lane fields inherit the corresponding global when omitted or set to null. Set a global override only when the value is genuinely shared by every lane, and put lane-specific differences in the lane itself. An external lane `runner.iam.role` is the exception to normal inheritance: inherited managed policies and additional trust policy JSON are suppressed because the module does not manage that role. +Nested global settings are the source of defaults for v2 runner configurations and the applicable singleton shared components. The GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume translated globals. Every v2 runner configuration must currently select `orchestration.webhook`; no other orchestration provider is implemented. The wrapper is the durable provider boundary for future mutually exclusive siblings. Migrated v2 consumers do not fall back to matching flat inputs; those values seed the stable-mode translation only. The singleton-specific webhook, binary-syncer, termination-watcher, and AMI-housekeeper settings all have nested owners. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Per-configuration values override globals only inside that runner configuration and never configure singleton resources. Nested defaults mirror established v1 behavior, while nullable runner-configuration fields inherit the corresponding global when omitted or set to null. Set a global override only when the value is genuinely shared by every runner configuration, and put configuration-specific differences in that configuration itself. An external `runner.iam.role` is the exception to normal inheritance: inherited managed policies and additional trust policy JSON are suppressed because the module does not manage that role. ```hcl module "multi_runner" { source = "github-aws-runners/github-runner/aws//modules/multi-runner" experimental = { - # Base tags for v2 queues and runner stacks and for translated singleton + # Base tags for v2 queues and runner configurations and for translated singleton # resources such as shared SSM, webhook, binary syncer, watcher, and AMI # housekeeper. tags = { - Workload = "runner-lanes" + Workload = "runner-configurations" ManagedBy = "terraform" } @@ -123,15 +129,14 @@ module "multi_runner" { } runner = { - os = "linux" - architecture = "arm64" - maximum_count = 4 - ephemeral = true + os = "linux" + architecture = "arm64" + ephemeral = true } github = { # Required in v2. These nested values are authoritative for shared SSM - # and every v2 runner stack. + # and every v2 runner configuration. app = var.github_app additional_apps = var.additional_github_apps repository_white_list = [ @@ -139,7 +144,7 @@ module "multi_runner" { ] # The URL also configures the shared termination watcher. TLS verification - # and the User-Agent remain runner-stack GitHub-client settings. + # and the User-Agent remain runner-config GitHub-client settings. enterprise_server = { url = var.ghes_url ssl_verify = true @@ -147,16 +152,8 @@ module "multi_runner" { user_agent = "github-aws-runners" } - # Shared-webhook routing and matcher storage are global-only. - webhook = { - queue_selection_strategy = "first" - eventbridge = { - enable = true - accept_events = [] - } - matcher_config_parameter_store_tier = "Standard" - } - + # Provider-neutral Lambda substrate shared by orchestration, runner SSM + # housekeeping, and other singleton consumers. lambda = { runtime = "nodejs24.x" architecture = "arm64" @@ -166,79 +163,99 @@ module "multi_runner" { bucket = var.lambda_artifact_bucket } } - scale = { - artifact = { - # Use zip instead for a local archive. Leave both fields null for the - # packaged runner archive. - zip = null - s3 = { - key = "runner-stack.zip" - object_version = null - } - } - } principals = var.additional_lambda_principals + } - # Singleton S3 wrappers select an object from the shared bucket. Merely - # setting lambda.artifact.s3.bucket does not switch a singleton from its - # packaged archive. + # Global defaults are grouped by orchestration provider. This block does + # not select a provider; each runner configuration has its own exact-one + # orchestration selector below. + orchestration = { webhook = { - artifact = { - zip = null - s3 = { - key = "webhook.zip" - object_version = null - } - } - api_gateway_access_log_settings = { - destination_arn = aws_cloudwatch_log_group.webhook_access.arn - format = "$context.requestId" - } - memory_size = 512 - timeout = 10 - tags = { - Component = "webhook" + runner = { + maximum_count = 4 } - } - scale_up = { - memory_size = 1024 - event_source_mapping = { - batch_size = 5 + queue_selection_strategy = "first" + eventbridge = { + enable = true + accept_events = [] } - } + matcher_config_parameter_store_tier = "Standard" - scale_down = { - memory_size = 512 - } + lambda = { + scale = { + artifact = { + # Use zip instead for a local archive. Leave both fields null for + # the packaged runner archive. + zip = null + s3 = { + key = "runner-config.zip" + object_version = null + } + } + } - pool = { - memory_size = 512 - } - } + # Component S3 wrappers select objects from the shared + # experimental.lambda artifact bucket. + webhook = { + artifact = { + zip = null + s3 = { + key = "webhook.zip" + object_version = null + } + } + api_gateway_access_log_settings = { + destination_arn = aws_cloudwatch_log_group.webhook_access.arn + format = "$context.requestId" + } + memory_size = 512 + timeout = 10 + tags = { + Component = "webhook" + } + } - # Global v2 build-queue defaults. Visibility is independent of the - # scale-up Lambda timeout and must remain at least six times that timeout. - # Encryption is global-only; lanes cannot override it. - queue = { - delay_webhook_event = 30 - job_queue_retention_in_seconds = 86400 - visibility_timeout_seconds = 180 - redrive_build_queue = { - enabled = false - maxReceiveCount = null - } - tags = { - QueueOwner = "platform" - } - encryption = { - sqs_managed_sse_enabled = null - kms_master_key_id = aws_kms_key.github_app_parameters.arn - kms_data_key_reuse_period_seconds = 300 + scale_up = { + memory_size = 1024 + event_source_mapping = { + batch_size = 5 + } + } + + scale_down = { + memory_size = 512 + } + + pool = { + memory_size = 512 + } + } + + # Global webhook build-queue defaults. Visibility is independent of the + # scale-up Lambda timeout and must remain at least six times that + # timeout. Encryption is global-only; runner configurations cannot override it. + queue = { + delay_webhook_event = 30 + job_queue_retention_in_seconds = 86400 + visibility_timeout_seconds = 180 + redrive_build_queue = { + enabled = false + maxReceiveCount = null + } + tags = { + QueueOwner = "platform" + } + encryption = { + sqs_managed_sse_enabled = null + kms_master_key_id = aws_kms_key.github_app_parameters.arn + kms_data_key_reuse_period_seconds = 300 + } + } } } - # Shared resources append app/webhook. Runner lanes append their lane key. + # Shared resources append app/webhook. Runner configurations append their key. ssm = { paths = { root = "/github-actions" @@ -247,7 +264,7 @@ module "multi_runner" { } # This ARN-valued scalar may be unknown until apply. It encrypts shared - # app parameters, configures the webhook, and grants runner-stack + # app parameters, configures the webhook, and grants runner-config # decrypt access. kms_key_id = aws_kms_key.github_app_parameters.arn @@ -286,8 +303,8 @@ module "multi_runner" { } # Shared v2 EC2 defaults. This block neither selects EC2 nor supplies - # lane-required provider fields. Runner-binary settings are global because - # each syncer is shared by lanes with the same OS and architecture. + # required provider fields. Runner-binary settings are global because each + # syncer is shared by runner configurations with the same OS and architecture. compute_provider = { ec2 = { vpc_id = var.vpc_id @@ -379,18 +396,32 @@ module "multi_runner" { Environment = "arm-runners" } - # This lane inherits the global OS and architecture but raises its cap. - runner = { - maximum_count = 8 - } + # Demand-control settings are selected through a typed orchestration + # provider. Webhook is the only supported provider today; future + # providers can be added as mutually exclusive siblings without moving + # these fields again. + orchestration = { + webhook = { + # This runner configuration overrides the webhook provider's global cap. + runner = { + maximum_count = 8 + } - lambda = { - scale_up = { - memory_size = 1536 + github = { + organization_runners = true + } + lambda = { + scale_up = { + memory_size = 1536 + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "arm64"]] + } } } - # A lane root is also a base; this resolves to + # A runner-configuration root is also a base; this resolves to # /github-actions/high-capacity/arm for this entry. ssm = { paths = { @@ -412,17 +443,13 @@ module "multi_runner" { } } - # Each lane selects exactly one provider and supplies its required + # Each runner configuration also selects exactly one compute provider and supplies its # provider-specific values here. compute_provider = { ec2 = { instance_types = ["m7g.large"] } } - - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "arm64"]] - } } } } @@ -431,39 +458,39 @@ module "multi_runner" { ## Inputs, tags, and outputs -The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider`, in addition to its lane map at `multi_runner_config`. Global `lambda` settings include runner-stack artifact selection, the shared artifact bucket, runtime, architecture, principals, networking, role, and tag values plus nested `scale_up`, `scale_down`, `webhook`, and `pool` blocks. Runtime, architecture, networking, role, and tag globals configure v2 runner stacks and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. `lambda.principals` configures v2 runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Root `webhook` owns shared routing and matcher storage; `lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. The termination watcher, AMI housekeeper, and runner-binary syncer have nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. +The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-configuration map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configurations and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-configuration SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration.webhook`: the maximum runner count, shared routing and matcher storage, queue defaults and encryption, runner-config scale artifact selection, and the webhook, scale-up, scale-down, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner configuration's separate `orchestration` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. -Each v2 lane groups provider-neutral settings by owner: `runner`, `github`, `queue`, `lambda` (with nested `scale_up`, `scale_down`, and `pool`), `job_retry`, `ssm`, and `observability`. Backend settings live under `compute_provider.`. A nullable lane field inherits its corresponding experimental global when omitted or null, except that an external lane runner role suppresses inherited IAM management inputs. Precedence within a runner stack is therefore a non-null lane override followed by the global nested value, including that field's nested schema default. Per-lane precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The lane runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. +Each v2 runner configuration groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration` provider wrapper. Its sole supported block today is `orchestration.webhook`, containing `runner.maximum_count`, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale_up`, `lambda.scale_down`, `lambda.pool`, and `job_retry`. A nullable per-configuration field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner configuration is therefore a non-null configuration override followed by the global nested value, including that field's nested schema default. Per-configuration precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. -Global `experimental.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null lane redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Lane fields under `experimental.multi_runner_config[].queue` override those global defaults, and lane queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].lambda.scale_up.timeout` controls the function only, while `experimental.multi_runner_config[].queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. +Global `experimental.orchestration.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-configuration redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration.webhook.queue` override those global defaults, and runner-configuration queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. -Queue encryption is global-only. Omitting the entire `experimental.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures only the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues. Lanes cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent. A distinct queue CMK reaches SQS, but current v2 webhook, scale-up, and job-retry IAM policies do not derive KMS grants from it; callers must grant those roles the required key permissions. The v1 translation retains the flat contract: lane delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. +Queue encryption is global-only. Omitting the entire `experimental.orchestration.webhook.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue. Runner configurations cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent and are forwarded separately to `orchestration-providers/webhook`: scale-up receives queue-key `kms:Decrypt`, job-retry receives queue-key `kms:Decrypt` and `kms:GenerateDataKey`, and both retain separate Parameter Store decrypt statements. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when it publishes to customer-managed encrypted queues. The v1 translation retains the flat contract: per-configuration delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. -Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner stacks: `app` is required and `additional_apps` defaults to `[]`. `github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-stack GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-stack client settings. Per-lane `github.organization_runners` remains a separate lane-owned registration-scope setting; lanes do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. +Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner configurations: `app` is required and `additional_apps` defaults to `[]`. `github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-config client settings. Neither field is a root `experimental` sibling. Per-configuration `orchestration.webhook.github.organization_runners` is the separate registration-scope setting; runner configurations do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. -Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner stack consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. +Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner configuration consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. -Global `experimental.webhook` configures the shared webhook's queue-selection strategy, EventBridge implementation and accepted events, and matcher-configuration Parameter Store tier. `webhook.eventbridge.enable` and the matcher tier must be known during planning because they select module or parameter-chunk shape. `first` deterministically chooses the first equally matched queue by priority, `random` spreads jobs among equals, and `all` sends a job to every match at the cost of multiple runner launches and registrations. +Global `experimental.orchestration.webhook` configures the shared webhook's queue-selection strategy, EventBridge implementation and accepted events, and matcher-configuration Parameter Store tier in addition to its queue and Lambda component defaults. `orchestration.webhook.eventbridge.enable` and the matcher tier must be known during planning because they select module or parameter-chunk shape. `first` deterministically chooses the first equally matched queue by priority, `random` spreads jobs among equals, and `all` sends a job to every match at the cost of multiple runner launches and registrations. -Global `ssm.paths.root` is the base for shared GitHub App and webhook parameters and for every lane. Shared resources append the global-only `ssm.paths.app` and `ssm.paths.webhook` segments, which default to `app` and `webhook`; normalization appends the lane key only for lane-owned roots, keeping runner parameters isolated. The derived global base defaults to `/github-action-runners/${prefix}`, while lane token and config segments default to `runners/tokens` and `runners/config`. Global `ssm.tags` augments the base tags on the shared Parameter Store module and also defaults lane-owned SSM tags; `ssm.parameters.tags` remains specific to lane-managed and runtime-created runner parameters. The global housekeeper defaults preserve the established schedule, enabled state, Lambda sizing, and cleanup behavior; a nullable lane field inherits those values. Avoid setting a global `ssm.housekeeper.config.tokenPath` unless every lane is intentionally meant to clean the same path; omitting it lets each stack derive its isolated token path. +Global `ssm.paths.root` is the base for shared GitHub App and webhook parameters and for every runner configuration. Shared resources append the global-only `ssm.paths.app` and `ssm.paths.webhook` segments, which default to `app` and `webhook`; normalization appends the runner-configuration key only for configuration-owned roots, keeping runner parameters isolated. The derived global base defaults to `/github-action-runners/${prefix}`, while runner token and config segments default to `runners/tokens` and `runners/config`. Global `ssm.tags` augments the base tags on the shared Parameter Store module and also defaults configuration-owned SSM tags; `ssm.parameters.tags` remains specific to configuration-managed and runtime-created runner parameters. The global housekeeper defaults preserve the established schedule, enabled state, Lambda artifact, sizing, and cleanup behavior; a nullable per-configuration field inherits those values. Avoid setting a global `ssm.housekeeper.config.tokenPath` unless every runner configuration is intentionally meant to clean the same path; omitting it lets each runner configuration derive its isolated token path. -Global `observability` values provide defaults for every runner stack and configure the applicable shared singleton consumers. Log level, retention, KMS key, class, and tracing configure the webhook, runner-binary syncer, termination watcher, and AMI housekeeper; metrics also configure the termination watcher. The nested defaults preserve established behavior: logs use level `info`, 180-day retention, no customer-managed KMS key, and class `STANDARD`; tracing defaults to no mode with HTTP and error capture disabled; metrics default to disabled in the `GitHub Runners` namespace while the rate-limit, job-retry, Spot-termination, and Spot-warning switches default to enabled. The two Spot switches are global termination-watcher settings and have no lane override. Other nullable lane observability fields inherit the global value. `observability.logs.tags` remains specific to lane-owned runner-stack log groups; shared singleton functions receive global `tags` and `lambda.tags`. The nullness of `observability.tracing.mode` must be known during planning because it selects X-Ray IAM statements and tracing blocks in runner-stack consumers. +Global `observability` values provide defaults for every runner configuration and configure the applicable shared singleton consumers. Log level, retention, KMS key, class, and tracing configure the webhook, runner-binary syncer, termination watcher, and AMI housekeeper; metrics also configure the termination watcher. The nested defaults preserve established behavior: logs use level `info`, 180-day retention, no customer-managed KMS key, and class `STANDARD`; tracing defaults to no mode with HTTP and error capture disabled; metrics default to disabled in the `GitHub Runners` namespace while the rate-limit, job-retry, Spot-termination, and Spot-warning switches default to enabled. The two Spot switches are global termination-watcher settings and have no per-configuration override. Other nullable runner-configuration observability fields inherit the global value. `observability.logs.tags` remains specific to runner-config-owned log groups; shared singleton functions receive global `tags` and `lambda.tags`. The nullness of `observability.tracing.mode` must be known during planning because it selects X-Ray IAM statements and tracing blocks in runner-config consumers. -The global `experimental.compute_provider` block owns v2 defaults for EC2 settings such as VPC and subnet IDs, managed-security-group behavior, egress rules, additional security groups, CloudWatch agent configuration, instance-profile path, key name, public IPv4 association, and tags. It also owns the shared AMI housekeeper, instance-termination watcher, and runner-binary distribution. It does not fall back to corresponding flat module inputs. VPC and subnet values must therefore be supplied through the global or lane EC2 block when needed. Global values should be set only when they are shared across every applicable lane. The global block never selects a provider and does not contain provider-specific required lane fields. Every lane must still populate exactly one typed provider block; that per-lane block selects the provider, supplies required fields such as EC2 `instance_types`, and preserves support for mixed-provider maps. +The global `experimental.compute_provider` block owns v2 defaults for EC2 settings such as VPC and subnet IDs, managed-security-group behavior, egress rules, additional security groups, CloudWatch agent configuration, instance-profile path, key name, public IPv4 association, and tags. It also owns the shared AMI housekeeper, instance-termination watcher, and runner-binary distribution. It does not fall back to corresponding flat module inputs. VPC and subnet values must therefore be supplied through the global or per-configuration EC2 block when needed. Global values should be set only when they are shared across every applicable runner configuration. The global block never selects a provider and does not contain provider-specific required runner-configuration fields. Every runner configuration must still populate exactly one typed provider block; that per-configuration block selects the provider, supplies required fields such as EC2 `instance_types`, and preserves support for mixed-provider maps. -`experimental.compute_provider.ec2.runner_binaries` owns whether EC2 lanes use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable lane `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. +`experimental.compute_provider.ec2.runner_binaries` owns whether EC2 runner configurations use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. -Runner-stack control-plane artifacts are selected globally through `experimental.lambda.scale.artifact.zip` or `experimental.lambda.scale.artifact.s3.{key,object_version}`. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. The webhook artifact remains separate under `lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. +Webhook-orchestration runner-config artifacts are selected globally through `experimental.orchestration.webhook.lambda.scale.artifact.zip` or `experimental.orchestration.webhook.lambda.scale.artifact.s3.{key,object_version}`. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The webhook artifact remains separate under `experimental.orchestration.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. -Tags follow the same ownership model but merge rather than replace. Within v2 queue and runner-stack scopes, experimental global tags merge with lane tags and then with component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes: shared SSM merges `experimental.tags` with `ssm.tags`, the webhook merges `lambda.tags` with `lambda.webhook.tags`, and the runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags` and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with lane `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. +Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-configuration tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes: shared SSM merges `experimental.tags` with `ssm.tags`, the webhook merges `experimental.lambda.tags` with `experimental.orchestration.webhook.lambda.webhook.tags`, and the runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags` and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-configuration `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. -Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and lane log-group tags. Tracing stays under `observability.tracing`, and metrics enablement, namespace, and individual metric switches stay under `observability.metrics`. +Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and runner-configuration log-group tags. Tracing stays under `observability.tracing`, and metrics enablement, namespace, and individual metric switches stay under `observability.metrics`. -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.`. The provider key is derived from the selected typed input block. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while 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. +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`, demand-control resources under `orchestration.webhook`, and provider-specific resources under `provider.`. The provider key is derived from the selected typed compute-provider block. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, scale-up resources at `runners_map_v2["configuration"].orchestration.webhook.scale_up`, and launch-template and runner-log artifacts under `runners_map_v2["configuration"].provider.ec2`. The webhook `pool` value is null when no pool configuration is supplied. ## Plan-time provider selection and IAM shape -Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Provider ownership inputs continue to use caller-known wrapper objects as discriminators. The shared SSM key is different: every relevant IAM consumer keeps a static statement and substitutes a harmless sentinel resource when the key is null, so `ssm.kms_key_id` can be an ARN-valued scalar that remains unknown until apply. The relevant configuration fragments are: +Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Provider ownership inputs continue to use caller-known wrapper objects as discriminators. The webhook orchestration leaves conditionally emit KMS statements from the nullable Parameter Store and build-queue key scalars; a null value omits the statement, while an apply-time-unknown ARN remains valid during planning. No placeholder or sentinel ARN is rendered. The relevant configuration fragments are: ```hcl ssm = { @@ -484,15 +511,15 @@ compute_provider = { } ``` -The populated `ec2` block tells both multi-runner routing and runner-stack dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_stacks` input forwards it unchanged at the runner-stack boundary. Within the provider block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. `ssm.kms_key_id` and values such as `observability.logs.kms_key_id`, which configure existing static resource or policy shapes, remain nullable scalar inputs. +The populated `ec2` block tells both multi-runner routing and runner-config dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_configs` input forwards it unchanged at the runner-config boundary. The orchestration wrapper follows the same exact-one rule. Within the compute block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. `ssm.kms_key_id`, `orchestration.webhook.queue.kms_key_id`, and values such as `observability.logs.kms_key_id` remain nullable scalar inputs even when their ARNs are unknown until apply. -For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts the shared GitHub App parameters, configures the webhook with the same key, and adds matching decrypt permissions to every runner stack so its control-plane functions can read those credentials. Its value may be unknown until apply because those IAM consumers retain a static statement shape. It does not select encryption for runtime-created lane runner parameters. Queue encryption is a separate global contract and may use a different CMK. +For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts the shared GitHub App parameters, configures the webhook with the same key, and adds matching decrypt permissions to every runner configuration so its control-plane functions can read those credentials. Its value may be unknown until apply. It does not select encryption for runtime-created runner parameters. Queue encryption is a separate global contract, may use a different CMK, and reaches only the scale-up consumer and job-retry publisher policies inside the webhook orchestration provider. ## Migration phases 1. **Phase 1 — v2 opt-in and canonical translation (current):** A non-empty experimental map opts the whole module instance into v2, while an empty map preserves the existing `module.runners["configuration"]` addresses. Both stable and experimental inputs already resolve through the same canonical pipeline. The v2 switch is not an in-place state migration. 2. **Phase 2 — deprecate legacy variables:** Deprecate the stable `multi_runner_config` and migrated flat inputs while retaining both dispatch paths and compatibility outputs for a release window. -3. **Phase 3 — remove legacy variables and migrate state:** In a breaking release, remove the deprecated inputs and flat output adapter, route the remaining canonical configuration through `runner-stack`, and ship tested `moved` blocks plus commands for addresses Terraform cannot move declaratively. +3. **Phase 3 — remove legacy variables and migrate state:** In a breaking release, remove the deprecated inputs and flat output adapter, route the remaining canonical configuration through `runner-config`, and ship tested `moved` blocks plus commands for addresses Terraform cannot move declaratively. 4. **Phase 4 — remove `modules/runners`:** After direct consumers have had a separate deprecation and migration window, delete the legacy module. A future compute provider must add a typed external input block, multi-runner normalization and routing, and an integration that returns the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Populating more than one external provider block, or selecting a block whose resources are not implemented, is intentionally rejected. diff --git a/mkdocs.yaml b/mkdocs.yaml index 6ec2922a2c..d974019b1b 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -57,6 +57,8 @@ nav: - Configuration: configuration.md - Getting started: getting-started.md - Security: security.md + - Architecture decisions: + - ADR-002 Runner orchestration provider boundary: adr/002-runner-orchestration-provider-boundary.md - Modules: - Runners (main): modules/runners.md - Submodules (public): diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index 7ac103d23c..d1c19cb963 100644 --- a/modules/compute-providers/ec2/README.md +++ b/modules/compute-providers/ec2/README.md @@ -1,23 +1,23 @@ # EC2 runner provider -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. +This internal module owns the EC2 compute implementation used by the common runner configuration. It creates the runner launch template, security group, instance profile, EC2 bootstrap parameters, and runner log groups. -The module returns one nested `provider` contract. It groups EC2-specific Lambda settings under `environment_variables`, permission requirements under `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, and EC2 artifacts under `resources`. The parent stack owns the shared runner role, provider-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 runner configuration owns the shared runner role, provider-policy attachments, Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. -EC2 is the only active compute provider. The parent 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. +EC2 is the only active compute provider. The parent runner configuration 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.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | @@ -28,7 +28,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 | @@ -58,23 +58,23 @@ 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 by compute-provider resources and policy documents. | `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 | +| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 available to compute-provider 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 available to compute-provider runner log groups.

- `logs.retention_in_days`: Retention period for provider-owned 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 identify resources created for the runner stack. | `string` | `"github-actions"` | no | -| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-stack manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-stack.
- `iam.path`: IAM path available to provider-managed IAM resources. 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
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | -| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `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 | +| [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no | +| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-config manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
- `iam.path`: IAM path available to provider-managed IAM resources. 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
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | +| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `paths.root`: Root Parameter Store path for the runner configuration.
- `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 available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| -| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-stack. | -| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-stack. | -| [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-stack. | -| [resources](#output\_resources) | Provider-specific EC2 resources exposed by runner-stack. | +| ---- | ----------- | +| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | +| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | +| [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | +| [resources](#output\_resources) | Provider-specific EC2 resources exposed by runner-config. | diff --git a/modules/compute-providers/ec2/control-plane.tf b/modules/compute-providers/ec2/control-plane.tf index fd9213d00e..d59a44f8bc 100644 --- a/modules/compute-providers/ec2/control-plane.tf +++ b/modules/compute-providers/ec2/control-plane.tf @@ -1,5 +1,5 @@ # EC2-specific IAM and environment fragments consumed by the common control -# plane in runner-stack. +# plane in runner-config. data "aws_iam_policy_document" "ami_id_ssm_parameter_read" { count = local.ami_id_ssm_external ? 1 : 0 diff --git a/modules/compute-providers/ec2/instance-profile.tf b/modules/compute-providers/ec2/instance-profile.tf index 68b8842d2d..f01a865f73 100644 --- a/modules/compute-providers/ec2/instance-profile.tf +++ b/modules/compute-providers/ec2/instance-profile.tf @@ -1,4 +1,4 @@ -# The common runner stack owns the role; EC2 owns the profile consumed by its +# The common runner configuration owns the role; EC2 owns the profile consumed by its # launch template. resource "aws_iam_instance_profile" "runner" { count = var.config.instance_profile == null ? 1 : 0 diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/ec2/outputs.tf index 4e536fcbbe..422383df0f 100644 --- a/modules/compute-providers/ec2/outputs.tf +++ b/modules/compute-providers/ec2/outputs.tf @@ -1,20 +1,20 @@ output "environment_variables" { - description = "Provider-specific Lambda environment variable fragments consumed by runner-stack." + description = "Provider-specific Lambda environment variable fragments consumed by runner-config." value = local.provider_environment_variables } output "policies" { - description = "Provider-specific IAM policy fragments consumed by runner-stack." + description = "Provider-specific IAM policy fragments consumed by runner-config." value = local.provider_policies } output "resources" { - description = "Provider-specific EC2 resources exposed by runner-stack." + description = "Provider-specific EC2 resources exposed by runner-config." value = local.provider_resources } output "provider" { - description = "Nested EC2 compute-provider contract consumed by runner-stack." + description = "Nested EC2 compute-provider contract consumed by runner-config." value = { environment_variables = local.provider_environment_variables policies = local.provider_policies diff --git a/modules/compute-providers/ec2/policies-runner.tf b/modules/compute-providers/ec2/policies-runner.tf index 785180cff3..c16077debc 100644 --- a/modules/compute-providers/ec2/policies-runner.tf +++ b/modules/compute-providers/ec2/policies-runner.tf @@ -1,4 +1,4 @@ -# EC2 runner permission documents returned to runner-stack for attachment to +# EC2 runner permission documents returned to runner-config for attachment to # the common runner role. data "aws_caller_identity" "current" {} diff --git a/modules/compute-providers/ec2/trust-policy/README.md b/modules/compute-providers/ec2/trust-policy/README.md index 3e0c0ce908..47b85fd9e5 100644 --- a/modules/compute-providers/ec2/trust-policy/README.md +++ b/modules/compute-providers/ec2/trust-policy/README.md @@ -1,19 +1,19 @@ # EC2 runner trust policy -This internal submodule builds the EC2 runner-role trust policy independently from EC2 resources that consume the runner role. It preserves the default EC2 service trust and optionally merges an additional IAM trust policy document supplied by the common runner stack. +This internal submodule builds the EC2 runner-role trust policy independently from EC2 resources that consume the runner role. It preserves the default EC2 service trust and optionally merges an additional IAM trust policy document supplied by the common runner configuration. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -23,19 +23,19 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [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.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the default EC2 runner-role trust policy. | `string` | `null` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [assume\_role\_policy](#output\_assume\_role\_policy) | EC2 runner-role trust policy with the optional additional trust policy merged into it. | diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf index 68d7e2d138..0826bd3f0b 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/ec2/variables.tf @@ -10,7 +10,7 @@ variable "aws_region" { } variable "prefix" { - description = "Prefix used to identify resources created for the runner stack." + description = "Prefix used to identify resources created for the runner configuration." type = string default = "github-actions" } @@ -23,7 +23,7 @@ variable "tags" { variable "config" { description = <<-EOT - EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner stack. + EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner configuration. - `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`. - `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults. @@ -75,7 +75,7 @@ variable "config" { - `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[].prefix_log_group`: Prefixes the log-group name with the runner configuration path. - `log_files[].file_path`: File or glob read by the CloudWatch agent. - `log_files[].log_stream_name`: CloudWatch log-stream name template. - `log_files[].log_class`: CloudWatch log-group class for the collected file. @@ -276,8 +276,8 @@ variable "runner" { - `hooks.job_completed`: Script installed as the runner job-completed hook. - `iam.role.arn`: Resolved runner-role ARN referenced by provider policies and resources. - `iam.role.name`: Resolved runner-role name used by provider resources. - - `iam.role.managed`: Whether runner-stack manages the resolved runner role. - - `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-stack. + - `iam.role.managed`: Whether runner-config manages the resolved runner role. + - `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config. - `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. EOT type = object({ @@ -326,7 +326,7 @@ variable "ssm" { description = <<-EOT Parameter Store paths and tag scopes available to compute-provider bootstrap resources. - - `paths.root`: Root Parameter Store path for the runner stack. + - `paths.root`: Root Parameter Store path for the runner configuration. - `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`. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 2eacfdab92..4a8eaec8ff 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -2,7 +2,7 @@ > This module replaces the top-level module to make it easy to create with one deployment multiple type of runners. -This module creates many runners with one or more GitHub Apps. The module utilizes the internal modules and deploys parts of the stack for each runner defined. +This module creates many runners with one or more GitHub Apps. It uses internal modules to deploy the resources for each runner configuration. ### GitHub App round-robin @@ -25,33 +25,33 @@ 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. -A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-stack` at `module.runner_stacks["configuration"]`. Sibling `experimental.tags`, `roles`, `runner`, `github`, `webhook`, `lambda`, `queue`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each lane can override supported lane-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-lane overrides affect only that lane, never a singleton shared component. A nullable lane field inherits its global value. Within v2 queue and runner-stack scopes, tags merge from global to lane and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. +A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-config` at `module.runner_configs["configuration"]`; a module-level moved block maps the former `module.runner_stacks` address to this composition name without recreating existing v2 resources. Sibling `experimental.tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each runner configuration can override supported configuration-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides affect only that runner configuration, never a singleton shared component. A nullable configuration field inherits its global value. Within v2 queue and runner-config scopes, tags merge from global to configuration and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. -The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental lane map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, global/lane precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-stack GitHub client settings, queue event mapping, Lambda artifact and principals, the pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable lanes are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_stacks` call directly iterates the gated final lanes, inlines the environment tag and live GitHub App and build-queue references, exposes the scale-up, scale-down, and pool aliases expected by `runner-stack`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. +The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, configuration-over-global precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, provider-owned scale artifacts, Lambda principals and shared artifact bucket, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references under `orchestration.webhook`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner stacks; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-stack GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-stack client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner configurations; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. -Shared singleton resources use the translated global contract without accepting per-lane overrides. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-stack, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Runner-stack control-plane artifacts come from global `experimental.lambda.scale.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources unselected uses the packaged runner archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves legacy S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. Root `experimental.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier. `lambda.webhook` owns webhook artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. +Shared singleton resources use the translated global contract without accepting per-configuration overrides. The shared webhook and its webhook-secret parameter retain their historical singleton addresses and are always created; only build-queue and matcher membership is filtered to runner configurations that select `orchestration.webhook`, so future non-webhook providers do not change the singleton lifecycle. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Global `experimental.lambda` contains only that shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults. Workflow-job webhook control-plane defaults live under `experimental.orchestration.webhook`. Runner-config control-plane artifacts come from `experimental.orchestration.webhook.lambda.scale.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Each runner configuration's SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the same shared artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation maps the legacy runner artifact into both canonical scale and SSM-housekeeper component contracts while preserving S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. `experimental.orchestration.webhook` owns queue selection, EventBridge routing, accepted event types, matcher-parameter tier, queue defaults, and webhook/scale/pool Lambda component defaults. Its `lambda.webhook` block owns webhook artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. -Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for lane-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the lane key only to lane roots. The default derived base is `/github-action-runners/${prefix}`, and lane token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner stack. It does not select encryption for runtime-created lane runner parameters. IAM consumers retain a static statement with a harmless sentinel resource when the value is null, so the configured ARN may remain unknown until apply. +Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for configuration-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the configuration key only to runner-configuration roots. The default derived base is `/github-action-runners/${prefix}`, and runner-configuration token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration. It does not select encryption for runtime-created runner-configuration parameters. Provider-owned runner-config IAM omits the KMS statement when the key is null and still accepts an ARN whose value is unknown until apply; the unchanged shared webhook retains its legacy policy handling. -The 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. Each lane still selects its provider from exactly one populated typed block under its own `compute_provider`; the global provider block supplies v2 defaults only. The same wrapper reaches runner-stack, whose direct input contract also validates exactly one populated provider block before dispatch. The EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. The selected lane 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. +Each runner configuration selects exactly one typed `orchestration` provider and exactly one typed `compute_provider`; the corresponding global blocks supply defaults without selecting providers. `runner-config` validates both selections, owns the common runner role and SSM housekeeper, and connects compute-provider capabilities to the selected orchestration provider. `orchestration-providers/webhook` owns scale-up, scale-down, pool, and job-retry resources. The EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. Provider-block selection must be known during planning because it determines the module graph. These child modules are internal implementation boundaries 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. +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 runner-config when it creates the role. An external role remains unmanaged and must already contain the required policies. Phase 1 supports both input contracts with deterministic precedence. When `experimental.multi_runner_config` is empty, the stable top-level `multi_runner_config` follows the unchanged legacy path. When the experimental map is non-empty, it becomes the complete runner map and stable entries are ignored. The maps are not merged. -Global `experimental.queue` owns the v2 defaults for build-queue delay, retention, visibility, redrive, tags, and encryption. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. Redrive defaults to disabled with a null `maxReceiveCount`; a null lane wrapper or leaf inherits the matching global value, and an enabled result requires `maxReceiveCount` greater than zero. Lane fields under `multi_runner_config[].queue` override the corresponding global queue defaults and lane tags merge over global queue tags. Encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. When supplying the block explicitly, provide all three leaves and use null for inactive KMS or SQS-managed settings. This block configures the multi-runner build queues and their dead-letter queues, not runner-stack job-retry queues. Its CMK is independent from `experimental.ssm.kms_key_id`; current v2 webhook, scale-up, and job-retry role policies do not derive grants from a distinct queue CMK, so callers must grant those roles the required key permissions. +Global `experimental.orchestration.webhook.queue` owns the v2 defaults for build-queue delay, retention, visibility, redrive, tags, and encryption. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. Redrive defaults to disabled with a null `maxReceiveCount`; a null configuration wrapper or leaf inherits the matching global value, and an enabled result requires `maxReceiveCount` greater than zero. Configuration fields under `multi_runner_config[].orchestration.webhook.queue` override the corresponding global queue defaults, and configuration tags merge over global queue tags. Encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. When supplying the block explicitly, provide all three leaves and use null for inactive KMS or SQS-managed settings. A non-null `kms_master_key_id` must be a KMS key ARN—not a key ID or alias—because it also becomes an IAM resource; a computed ARN may remain unknown until apply. This block configures the multi-runner build queues and their dead-letter queues, not runner-config job-retry queues. Its CMK is independent from `experimental.ssm.kms_key_id` and is forwarded separately to each webhook runner configuration: provider-owned IAM grants scale-up `kms:Decrypt` and job-retry `kms:Decrypt` plus `kms:GenerateDataKey`. The shared webhook module is intentionally unchanged, so its producer role does not derive queue-CMK permissions from this field; ensure the key policy or caller-managed IAM covers producer access when required. -For v2, `multi_runner_config[].queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].lambda.scale_up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. +For v2, `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. -Global `experimental.compute_provider.ec2.runner_binaries` owns the shared distribution-bucket and syncer defaults. It covers lane enablement, bucket encryption, tags, versioning and access logging, plus the syncer artifact, Lambda sizing, and schedule. `runner_binaries.enabled` defaults to `true`, while a nullable lane `compute_provider.ec2.binaries_syncer.enabled` overrides that default; enabled distributions are created once per unique operating-system and architecture pair. The resolved enable value, `s3.encryption.enabled`, the nullness of `s3.encryption.kms_master_key_id`, and the nullness of `s3.logging.bucket` must be known during planning because they determine module or resource shape. A distribution CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from it; attach that permission separately when using a CMK. +Global `experimental.compute_provider.ec2.runner_binaries` owns the shared distribution-bucket and syncer defaults. It covers runner-configuration enablement, bucket encryption, tags, versioning and access logging, plus the syncer artifact, Lambda sizing, and schedule. `runner_binaries.enabled` defaults to `true`, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides that default; enabled distributions are created once per unique operating-system and architecture pair. The resolved enable value, `s3.encryption.enabled`, the nullness of `s3.encryption.kms_master_key_id`, and the nullness of `s3.logging.bucket` must be known during planning because they determine module or resource shape. A distribution CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from it; attach that permission separately when using a CMK. ### V2 tagging -For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `queue.tags`, and lane-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `lambda.scale_up.tags`, `lambda.scale_down.tags`, `lambda.webhook.tags`, `lambda.pool.tags`, `job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain lane-owned runner-stack log-group tags. In stable mode, translation preserves the existing flat behavior. +For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `orchestration.webhook.queue.tags`, and configuration-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `orchestration.webhook.lambda.scale_up.tags`, `orchestration.webhook.lambda.scale_down.tags`, `orchestration.webhook.lambda.webhook.tags`, `orchestration.webhook.lambda.pool.tags`, `orchestration.webhook.job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `orchestration.webhook.lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain configuration-owned runner-config log-group tags. In stable mode, translation preserves the existing flat behavior. -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. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. +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`, with demand-control resources canonically grouped under `orchestration.webhook`, while `runners_map` is empty. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].orchestration.webhook.scale_up.lambda`, `.log_group`, and `.role` for scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. Existing flat `scale_up`, `scale_down`, and `pool` output aliases remain available for compatibility. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. ### Multi-runner v2 migration roadmap @@ -59,13 +59,13 @@ Here, v1 and v2 refer to the stable top-level `multi_runner_config` and `experim #### Phase 1 — Add v2 as a module-level opt-in (current) -Both input contracts are available in the same module release. An empty `experimental.multi_runner_config` keeps every stable top-level `multi_runner_config` entry on the existing `modules/runners` implementation at `module.runners["configuration"]`, retaining its flat `runners_map` output and Terraform addresses. Internally, the flat input is projected through `local.translated_experimental_base` and finalized as `local.translated_experimental`; `runners.tf` adapts those canonical lanes to the existing module call instead of forwarding the original v1 object. A non-empty experimental map selects `module.runner_stacks["configuration"]` and the nested `runners_map_v2` output shape; it takes priority over stable entries, and the maps are not combined. +Both input contracts are available in the same module release. An empty `experimental.multi_runner_config` keeps every stable top-level `multi_runner_config` entry on the existing `modules/runners` implementation at `module.runners["configuration"]`, retaining its flat `runners_map` output and Terraform addresses. Internally, the flat input is projected through `local.translated_experimental_base` and finalized as `local.translated_experimental`; `runners.tf` adapts those canonical runner configurations to the existing module call instead of forwarding the original v1 object. A non-empty experimental map selects `module.runner_configs["configuration"]` and the nested `runners_map_v2` output shape; it takes priority over stable entries, and the maps are not combined. Compatibility guarantee: upgrading while leaving `experimental.multi_runner_config` 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 its existing translated representation becomes the dispatch source for `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. +`multi_runner_config` remains accepted but is deprecated, and its existing translated representation becomes the dispatch source for `runner-config`. 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. @@ -81,7 +81,7 @@ Removing `modules/runners` is a separate future change. It requires its own comp For each configuration: -- When globally enabled or enabled by a lane override, 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. +- When globally enabled or enabled by a per-configuration override, 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. - For each configuration a queue is created and [runner module](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/runners/) is deployed ## Matching @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,19 +167,19 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [random](#provider\_random) | ~> 3.0 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | +| [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | ## 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 | -| [runner\_stacks](#module\_runner\_stacks) | ../runner-stack | n/a | +| [runner\_configs](#module\_runner\_configs) | ../runner-config | n/a | | [runners](#module\_runners) | ../runners | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | | [webhook](#module\_webhook) | ../webhook | n/a | @@ -187,19 +187,20 @@ 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 | | [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [terraform_data.validate_experimental](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | 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.deny_insecure_transport_build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.deny_insecure_transport_build_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -215,7 +216,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`.
- `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive.
- `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null.
- `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it.
- `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK.
- `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].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.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(number, null)
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

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

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), 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({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration`, where exactly one typed provider block must be non-null.
- `orchestration.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration.webhook.lambda.scale.artifact`: Runner-config scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration.webhook.lambda.scale.artifact.zip`: Optional local path to the runner-config scale-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-config scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.scale.artifact.s3.key`: Object key of the runner-config scale-control-plane Lambda archive.
- `orchestration.webhook.lambda.scale.artifact.s3.object_version`: Optional object version of the runner-config scale-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration.webhook.lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration.webhook.lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration.webhook.lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration.webhook.lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale_up.timeout` that inherits it.
- `orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
maximum_count = optional(number, null)
}), {})

lambda = optional(object({
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = optional(object({
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

})), {})
})
| `{}` | 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
}))
})
| `null` | no | @@ -284,12 +285,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) | 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. | +| [runners\_map\_v2](#output\_runners\_map\_v2) | Experimental v2 runner resources keyed by runner configuration. The orchestration object is canonical; scale\_up, scale\_down, and pool remain compatibility aliases. | | [ssm\_parameters](#output\_ssm\_parameters) | n/a | | [webhook](#output\_webhook) | n/a | diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index c7de9d073a..5cab9da81f 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -1,7 +1,7 @@ # Project stable v1 inputs into the experimental schema, then resolve every -# lane against the experimental global defaults. The base object remains +# runner configuration against the experimental global defaults. The base object remains # schema-compatible with var.experimental; the final canonical object adds -# effective lane fields and the resource-backed binary distribution consumed +# effective per-configuration fields and the resource-backed binary distribution consumed # by downstream runner modules. locals { # A non-empty experimental map is the module-level v2 opt-in. Stable v1 and @@ -26,7 +26,6 @@ locals { name_prefix = "" run_as_root = false run_as = "ec2-user" - maximum_count = null ephemeral = false jit_config_enabled = null auto_update_disabled = false @@ -55,27 +54,12 @@ locals { user_agent = var.user_agent } - webhook = { - queue_selection_strategy = var.queue_selection_strategy - eventbridge = var.eventbridge - matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier - } - lambda = { artifact = { s3 = { bucket = var.lambda_s3_bucket } } - scale = { - artifact = { - zip = var.lambda_s3_bucket == null ? var.runners_lambda_zip : null - s3 = var.lambda_s3_bucket == null ? null : { - key = var.runners_lambda_s3_key - object_version = var.runners_lambda_s3_object_version - } - } - } runtime = var.lambda_runtime architecture = var.lambda_architecture principals = var.lambda_principals @@ -86,59 +70,80 @@ locals { path = null permissions_boundary = null } - scale_up = { - memory_size = var.scale_up_lambda_memory_size - timeout = var.runners_scale_up_lambda_timeout - reserved_concurrent_executions = 1 - job_queued_check_enabled = null - event_source_mapping = { - 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 = {} - } - scale_down = { - memory_size = var.scale_down_lambda_memory_size - timeout = var.runners_scale_down_lambda_timeout - schedule_expression = "cron(*/5 * * * ? *)" - minimum_running_time_in_minutes = null - idle_config = [] - tags = {} - } + } + + orchestration = { webhook = { - artifact = { - zip = var.lambda_s3_bucket == null ? var.webhook_lambda_zip : null - s3 = var.lambda_s3_bucket == null ? null : { - key = var.webhook_lambda_s3_key - object_version = var.webhook_lambda_s3_object_version + queue_selection_strategy = var.queue_selection_strategy + eventbridge = var.eventbridge + matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier + runner = { + maximum_count = null + } + lambda = { + scale = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.runners_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.runners_lambda_s3_key + object_version = var.runners_lambda_s3_object_version + } + } + } + scale_up = { + memory_size = var.scale_up_lambda_memory_size + timeout = var.runners_scale_up_lambda_timeout + reserved_concurrent_executions = 1 + job_queued_check_enabled = null + event_source_mapping = { + 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 = {} + } + scale_down = { + memory_size = var.scale_down_lambda_memory_size + timeout = var.runners_scale_down_lambda_timeout + schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = null + idle_config = [] + tags = {} + } + webhook = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.webhook_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.webhook_lambda_s3_key + object_version = var.webhook_lambda_s3_object_version + } + } + api_gateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings + memory_size = var.webhook_lambda_memory_size + timeout = var.webhook_lambda_timeout + tags = {} + } + pool = { + memory_size = 512 + timeout = var.pool_lambda_timeout + reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + config = [] + include_busy_runners = false + runner_owner = null + tags = {} } } - api_gateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings - memory_size = var.webhook_lambda_memory_size - timeout = var.webhook_lambda_timeout - tags = {} - } - pool = { - memory_size = 512 - timeout = var.pool_lambda_timeout - reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - config = [] - include_busy_runners = false - runner_owner = null - tags = {} - } - } - - queue = { - delay_webhook_event = 30 - job_queue_retention_in_seconds = 86400 - visibility_timeout_seconds = var.runners_scale_up_lambda_timeout - redrive_build_queue = { - enabled = false - maxReceiveCount = null + queue = { + delay_webhook_event = 30 + job_queue_retention_in_seconds = 86400 + visibility_timeout_seconds = var.runners_scale_up_lambda_timeout + redrive_build_queue = { + enabled = false + maxReceiveCount = null + } + tags = {} + encryption = var.queue_encryption + } } - tags = {} - encryption = var.queue_encryption } ssm = { @@ -159,6 +164,13 @@ locals { state = var.runners_ssm_housekeeper.enabled ? "ENABLED" : "DISABLED" tags = {} lambda = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.runners_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.runners_lambda_s3_key + object_version = var.runners_lambda_s3_object_version + } + } memory_size = var.runners_ssm_housekeeper.lambda_memory_size timeout = var.runners_ssm_housekeeper.lambda_timeout } @@ -293,7 +305,6 @@ locals { 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 @@ -316,10 +327,6 @@ locals { } } - github = { - organization_runners = v.runner_config.enable_organization_runners - } - lambda = { runtime = null architecture = null @@ -330,54 +337,71 @@ locals { path = null permissions_boundary = null } - scale_up = { - memory_size = null - timeout = null - reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions - job_queued_check_enabled = v.runner_config.enable_job_queued_check - 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 - } - tags = {} - } - scale_down = { - memory_size = null - timeout = null - 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 = { - memory_size = null - timeout = null - reserved_concurrent_executions = null - config = v.runner_config.pool_config - include_busy_runners = false - runner_owner = v.runner_config.pool_runner_owner - tags = {} - } } - queue = { - delay_webhook_event = v.runner_config.delay_webhook_event - job_queue_retention_in_seconds = v.runner_config.job_queue_retention_in_seconds - visibility_timeout_seconds = var.runners_scale_up_lambda_timeout - redrive_build_queue = v.redrive_build_queue - tags = {} - } + orchestration = { + webhook = { + runner = { + maximum_count = v.runner_config.runners_maximum_count + } - 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 - tags = {} - lambda = { - memory_size = v.runner_config.job_retry.lambda_memory_size - reserved_concurrent_executions = 1 - timeout = v.runner_config.job_retry.lambda_timeout + github = { + organization_runners = v.runner_config.enable_organization_runners + } + + matcherConfig = v.matcherConfig + + lambda = { + scale_up = { + memory_size = null + timeout = null + reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions + job_queued_check_enabled = v.runner_config.enable_job_queued_check + 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 + } + tags = {} + } + scale_down = { + memory_size = null + timeout = null + 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 = { + memory_size = null + timeout = null + reserved_concurrent_executions = null + config = v.runner_config.pool_config + include_busy_runners = false + runner_owner = v.runner_config.pool_runner_owner + tags = {} + } + } + + queue = { + delay_webhook_event = v.runner_config.delay_webhook_event + job_queue_retention_in_seconds = v.runner_config.job_queue_retention_in_seconds + visibility_timeout_seconds = var.runners_scale_up_lambda_timeout + redrive_build_queue = v.redrive_build_queue + tags = {} + } + + 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 + tags = {} + lambda = { + memory_size = v.runner_config.job_retry.lambda_memory_size + reserved_concurrent_executions = 1 + timeout = v.runner_config.job_retry.lambda_timeout + } + } } } @@ -396,6 +420,10 @@ locals { state = null tags = {} lambda = { + artifact = { + zip = null + s3 = null + } memory_size = null timeout = null } @@ -495,8 +523,6 @@ locals { tags = v.runner_config.runner_ec2_tags } } - - matcherConfig = v.matcherConfig } } } @@ -518,7 +544,6 @@ locals { name_prefix = v.runner.name_prefix != null ? v.runner.name_prefix : local.raw_translated_experimental.runner.name_prefix run_as_root = coalesce(v.runner.run_as_root, local.raw_translated_experimental.runner.run_as_root) run_as = coalesce(v.runner.run_as, local.raw_translated_experimental.runner.run_as) - maximum_count = try(coalesce(v.runner.maximum_count, local.raw_translated_experimental.runner.maximum_count), null) ephemeral = coalesce(v.runner.ephemeral, local.raw_translated_experimental.runner.ephemeral) jit_config_enabled = try(coalesce(v.runner.jit_config_enabled, local.raw_translated_experimental.runner.jit_config_enabled), null) auto_update_disabled = coalesce(v.runner.auto_update_disabled, local.raw_translated_experimental.runner.auto_update_disabled) @@ -558,58 +583,72 @@ locals { local.raw_translated_experimental.roles.permissions_boundary, ), null) } - scale_up = merge(v.lambda.scale_up, { - memory_size = coalesce(v.lambda.scale_up.memory_size, local.raw_translated_experimental.lambda.scale_up.memory_size) - timeout = coalesce(v.lambda.scale_up.timeout, local.raw_translated_experimental.lambda.scale_up.timeout) - reserved_concurrent_executions = coalesce(v.lambda.scale_up.reserved_concurrent_executions, local.raw_translated_experimental.lambda.scale_up.reserved_concurrent_executions) - job_queued_check_enabled = try(coalesce(v.lambda.scale_up.job_queued_check_enabled, local.raw_translated_experimental.lambda.scale_up.job_queued_check_enabled), null) - event_source_mapping = { - batch_size = coalesce( - v.lambda.scale_up.event_source_mapping.batch_size, - local.raw_translated_experimental.lambda.scale_up.event_source_mapping.batch_size, - ) - maximum_batching_window_in_seconds = coalesce( - v.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, - local.raw_translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, - ) - } - tags = merge(local.raw_translated_experimental.lambda.scale_up.tags, v.lambda.scale_up.tags) - }) - scale_down = merge(v.lambda.scale_down, { - memory_size = coalesce(v.lambda.scale_down.memory_size, local.raw_translated_experimental.lambda.scale_down.memory_size) - timeout = coalesce(v.lambda.scale_down.timeout, local.raw_translated_experimental.lambda.scale_down.timeout) - schedule_expression = coalesce(v.lambda.scale_down.schedule_expression, local.raw_translated_experimental.lambda.scale_down.schedule_expression) - minimum_running_time_in_minutes = try(coalesce(v.lambda.scale_down.minimum_running_time_in_minutes, local.raw_translated_experimental.lambda.scale_down.minimum_running_time_in_minutes), null) - idle_config = v.lambda.scale_down.idle_config != null ? v.lambda.scale_down.idle_config : local.raw_translated_experimental.lambda.scale_down.idle_config - tags = merge(local.raw_translated_experimental.lambda.scale_down.tags, v.lambda.scale_down.tags) - }) - pool = merge(v.lambda.pool, { - memory_size = coalesce(v.lambda.pool.memory_size, local.raw_translated_experimental.lambda.pool.memory_size) - timeout = coalesce(v.lambda.pool.timeout, local.raw_translated_experimental.lambda.pool.timeout) - reserved_concurrent_executions = coalesce(v.lambda.pool.reserved_concurrent_executions, local.raw_translated_experimental.lambda.pool.reserved_concurrent_executions) - config = v.lambda.pool.config != null ? v.lambda.pool.config : local.raw_translated_experimental.lambda.pool.config - include_busy_runners = coalesce(v.lambda.pool.include_busy_runners, local.raw_translated_experimental.lambda.pool.include_busy_runners) - runner_owner = try(coalesce(v.lambda.pool.runner_owner, local.raw_translated_experimental.lambda.pool.runner_owner), null) - tags = merge(local.raw_translated_experimental.lambda.pool.tags, v.lambda.pool.tags) - }) }) - queue = merge(v.queue, { - delay_webhook_event = coalesce(v.queue.delay_webhook_event, local.raw_translated_experimental.queue.delay_webhook_event) - job_queue_retention_in_seconds = coalesce(v.queue.job_queue_retention_in_seconds, local.raw_translated_experimental.queue.job_queue_retention_in_seconds) - visibility_timeout_seconds = coalesce(v.queue.visibility_timeout_seconds, local.raw_translated_experimental.queue.visibility_timeout_seconds) - redrive_build_queue = { - enabled = try( - coalesce(try(v.queue.redrive_build_queue.enabled, null), local.raw_translated_experimental.queue.redrive_build_queue.enabled), - local.raw_translated_experimental.queue.redrive_build_queue.enabled, - ) - maxReceiveCount = try( - coalesce(try(v.queue.redrive_build_queue.maxReceiveCount, null), local.raw_translated_experimental.queue.redrive_build_queue.maxReceiveCount), - null, - ) - } - tags = merge(local.raw_translated_experimental.queue.tags, v.queue.tags) - }) + orchestration = { + webhook = v.orchestration.webhook == null ? null : merge(v.orchestration.webhook, { + runner = { + maximum_count = try(coalesce( + v.orchestration.webhook.runner.maximum_count, + local.raw_translated_experimental.orchestration.webhook.runner.maximum_count, + ), null) + } + + lambda = merge(v.orchestration.webhook.lambda, { + scale_up = merge(v.orchestration.webhook.lambda.scale_up, { + memory_size = coalesce(v.orchestration.webhook.lambda.scale_up.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.memory_size) + timeout = coalesce(v.orchestration.webhook.lambda.scale_up.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.timeout) + reserved_concurrent_executions = coalesce(v.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions) + job_queued_check_enabled = try(coalesce(v.orchestration.webhook.lambda.scale_up.job_queued_check_enabled, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.job_queued_check_enabled), null) + event_source_mapping = { + batch_size = coalesce( + v.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size, + local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size, + ) + maximum_batching_window_in_seconds = coalesce( + v.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, + local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, + ) + } + tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.tags, v.orchestration.webhook.lambda.scale_up.tags) + }) + scale_down = merge(v.orchestration.webhook.lambda.scale_down, { + memory_size = coalesce(v.orchestration.webhook.lambda.scale_down.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.memory_size) + timeout = coalesce(v.orchestration.webhook.lambda.scale_down.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.timeout) + schedule_expression = coalesce(v.orchestration.webhook.lambda.scale_down.schedule_expression, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.schedule_expression) + minimum_running_time_in_minutes = try(coalesce(v.orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes), null) + idle_config = v.orchestration.webhook.lambda.scale_down.idle_config != null ? v.orchestration.webhook.lambda.scale_down.idle_config : local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.idle_config + tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.tags, v.orchestration.webhook.lambda.scale_down.tags) + }) + pool = merge(v.orchestration.webhook.lambda.pool, { + memory_size = coalesce(v.orchestration.webhook.lambda.pool.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.pool.memory_size) + timeout = coalesce(v.orchestration.webhook.lambda.pool.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.pool.timeout) + reserved_concurrent_executions = coalesce(v.orchestration.webhook.lambda.pool.reserved_concurrent_executions, local.raw_translated_experimental.orchestration.webhook.lambda.pool.reserved_concurrent_executions) + config = v.orchestration.webhook.lambda.pool.config != null ? v.orchestration.webhook.lambda.pool.config : local.raw_translated_experimental.orchestration.webhook.lambda.pool.config + include_busy_runners = coalesce(v.orchestration.webhook.lambda.pool.include_busy_runners, local.raw_translated_experimental.orchestration.webhook.lambda.pool.include_busy_runners) + runner_owner = try(coalesce(v.orchestration.webhook.lambda.pool.runner_owner, local.raw_translated_experimental.orchestration.webhook.lambda.pool.runner_owner), null) + tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.pool.tags, v.orchestration.webhook.lambda.pool.tags) + }) + }) + + queue = merge(v.orchestration.webhook.queue, { + delay_webhook_event = coalesce(v.orchestration.webhook.queue.delay_webhook_event, local.raw_translated_experimental.orchestration.webhook.queue.delay_webhook_event) + job_queue_retention_in_seconds = coalesce(v.orchestration.webhook.queue.job_queue_retention_in_seconds, local.raw_translated_experimental.orchestration.webhook.queue.job_queue_retention_in_seconds) + visibility_timeout_seconds = coalesce(v.orchestration.webhook.queue.visibility_timeout_seconds, local.raw_translated_experimental.orchestration.webhook.queue.visibility_timeout_seconds) + redrive_build_queue = { + enabled = try( + coalesce(try(v.orchestration.webhook.queue.redrive_build_queue.enabled, null), local.raw_translated_experimental.orchestration.webhook.queue.redrive_build_queue.enabled), + local.raw_translated_experimental.orchestration.webhook.queue.redrive_build_queue.enabled, + ) + maxReceiveCount = try( + coalesce(try(v.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount, null), local.raw_translated_experimental.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount), + null, + ) + } + tags = merge(local.raw_translated_experimental.orchestration.webhook.queue.tags, v.orchestration.webhook.queue.tags) + }) + }) + } ssm = merge(v.ssm, { paths = { @@ -630,6 +669,15 @@ locals { state = coalesce(v.ssm.housekeeper.state, local.raw_translated_experimental.ssm.housekeeper.state) tags = merge(local.raw_translated_experimental.ssm.housekeeper.tags, v.ssm.housekeeper.tags) lambda = { + artifact = { + zip = v.ssm.housekeeper.lambda.artifact.s3 != null ? null : try(coalesce( + v.ssm.housekeeper.lambda.artifact.zip, + local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.zip, + ), null) + s3 = v.ssm.housekeeper.lambda.artifact.s3 != null ? v.ssm.housekeeper.lambda.artifact.s3 : ( + v.ssm.housekeeper.lambda.artifact.zip != null ? null : local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3 + ) + } memory_size = coalesce(v.ssm.housekeeper.lambda.memory_size, local.raw_translated_experimental.ssm.housekeeper.lambda.memory_size) timeout = coalesce(v.ssm.housekeeper.lambda.timeout, local.raw_translated_experimental.ssm.housekeeper.lambda.timeout) } @@ -711,37 +759,33 @@ locals { v.runner.os, v.runner.architecture, ]), - flatten(v.matcherConfig.labelMatchers), + v.orchestration.webhook == null ? [] : flatten(v.orchestration.webhook.matcherConfig.labelMatchers), compact(v.runner.extra_labels), )) }) - github = merge(v.github, { + github = { enterprise_server = local.translated_experimental_base.github.enterprise_server user_agent = local.translated_experimental_base.github.user_agent - }) - - queue = merge(v.queue, { - event_source_mapping = v.lambda.scale_up.event_source_mapping - }) + } lambda = merge(v.lambda, { - zip = local.translated_experimental_base.lambda.scale.artifact.zip - s3 = { - bucket = local.translated_experimental_base.lambda.scale.artifact.s3 == null ? null : local.translated_experimental_base.lambda.artifact.s3.bucket - key = try(local.translated_experimental_base.lambda.scale.artifact.s3.key, null) - object_version = try(local.translated_experimental_base.lambda.scale.artifact.s3.object_version, null) - } + artifact = local.translated_experimental_base.lambda.artifact principals = local.translated_experimental_base.lambda.principals - pool = merge(v.lambda.pool, { - lambda = { - memory_size = v.lambda.pool.memory_size - timeout = v.lambda.pool.timeout - reserved_concurrent_executions = v.lambda.pool.reserved_concurrent_executions - } - }) }) + orchestration = { + webhook = v.orchestration.webhook == null ? null : merge(v.orchestration.webhook, { + queue = merge(v.orchestration.webhook.queue, { + kms_key_id = local.translated_experimental_base.orchestration.webhook.queue.encryption.kms_master_key_id + }) + + lambda = merge(v.orchestration.webhook.lambda, { + scale = local.translated_experimental_base.orchestration.webhook.lambda.scale + }) + }) + } + ssm = merge(v.ssm, { kms_key_id = local.translated_experimental_base.ssm.kms_key_id }) diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 1491fa1d22..9576a45c68 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -23,13 +23,14 @@ output "runners_map" { } 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 + description = "Experimental v2 runner resources keyed by runner configuration. The orchestration object is canonical; scale_up, scale_down, and pool remain compatibility aliases." + value = { for runner_key, runner in module.runner_configs : runner_key => { + runner = runner.runner + orchestration = runner.orchestration + scale_up = runner.scale_up + scale_down = runner.scale_down + pool = runner.pool + provider = runner.provider } } } @@ -52,8 +53,8 @@ output "webhook" { lambda_role = module.webhook.role endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}" webhook = module.webhook.webhook - dispatcher = local.translated_experimental.webhook.eventbridge.enable ? module.webhook.dispatcher : null - eventbridge = local.translated_experimental.webhook.eventbridge.enable ? module.webhook.eventbridge : null + dispatcher = local.translated_experimental.orchestration.webhook.eventbridge.enable ? module.webhook.dispatcher : null + eventbridge = local.translated_experimental.orchestration.webhook.eventbridge.enable ? module.webhook.eventbridge : null } } diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index db05c7b6a6..f3d49e9448 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -1,4 +1,6 @@ -data "aws_iam_policy_document" "deny_insecure_transport" { +data "aws_iam_policy_document" "deny_insecure_transport_build" { + for_each = local.webhook_runner_config + statement { sid = "DenyInsecureTransport" @@ -13,9 +15,7 @@ data "aws_iam_policy_document" "deny_insecure_transport" { "sqs:*" ] - resources = [ - "*" - ] + resources = [aws_sqs_queue.queued_builds[each.key].arn] condition { test = "Bool" @@ -26,49 +26,76 @@ data "aws_iam_policy_document" "deny_insecure_transport" { } resource "aws_sqs_queue" "queued_builds" { - for_each = local.translated_experimental.multi_runner_config + for_each = local.webhook_runner_config name = "${var.prefix}-${each.key}-queued-builds" - delay_seconds = each.value.queue.delay_webhook_event - visibility_timeout_seconds = each.value.queue.visibility_timeout_seconds - message_retention_seconds = each.value.queue.job_queue_retention_in_seconds + delay_seconds = each.value.orchestration.webhook.queue.delay_webhook_event + visibility_timeout_seconds = each.value.orchestration.webhook.queue.visibility_timeout_seconds + message_retention_seconds = each.value.orchestration.webhook.queue.job_queue_retention_in_seconds receive_wait_time_seconds = 0 - redrive_policy = each.value.queue.redrive_build_queue.enabled ? jsonencode({ + redrive_policy = each.value.orchestration.webhook.queue.redrive_build_queue.enabled ? jsonencode({ deadLetterTargetArn = aws_sqs_queue.queued_builds_dlq[each.key].arn, - maxReceiveCount = each.value.queue.redrive_build_queue.maxReceiveCount + maxReceiveCount = each.value.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount }) : null - sqs_managed_sse_enabled = local.translated_experimental.queue.encryption.sqs_managed_sse_enabled - kms_master_key_id = local.translated_experimental.queue.encryption.kms_master_key_id - kms_data_key_reuse_period_seconds = local.translated_experimental.queue.encryption.kms_data_key_reuse_period_seconds + sqs_managed_sse_enabled = local.translated_experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled + kms_master_key_id = local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = local.translated_experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds tags = merge( local.translated_experimental.tags, each.value.tags, - each.value.queue.tags, + each.value.orchestration.webhook.queue.tags, ) } resource "aws_sqs_queue_policy" "build_queue_policy" { - for_each = local.translated_experimental.multi_runner_config + for_each = local.webhook_runner_config queue_url = aws_sqs_queue.queued_builds[each.key].id - policy = data.aws_iam_policy_document.deny_insecure_transport.json + policy = data.aws_iam_policy_document.deny_insecure_transport_build[each.key].json } resource "aws_sqs_queue" "queued_builds_dlq" { - for_each = { for config, values in local.translated_experimental.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } + for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration.webhook.queue.redrive_build_queue.enabled } name = "${var.prefix}-${each.key}-queued-builds_dead_letter" - sqs_managed_sse_enabled = local.translated_experimental.queue.encryption.sqs_managed_sse_enabled - kms_master_key_id = local.translated_experimental.queue.encryption.kms_master_key_id - kms_data_key_reuse_period_seconds = local.translated_experimental.queue.encryption.kms_data_key_reuse_period_seconds + sqs_managed_sse_enabled = local.translated_experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled + kms_master_key_id = local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = local.translated_experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds tags = merge( local.translated_experimental.tags, each.value.tags, - each.value.queue.tags, + each.value.orchestration.webhook.queue.tags, ) } +data "aws_iam_policy_document" "deny_insecure_transport_build_dlq" { + for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration.webhook.queue.redrive_build_queue.enabled } + + statement { + sid = "DenyInsecureTransport" + + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["*"] + } + + actions = [ + "sqs:*" + ] + + resources = [aws_sqs_queue.queued_builds_dlq[each.key].arn] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { - for_each = { for config, values in local.translated_experimental.multi_runner_config : config => values if values.queue.redrive_build_queue.enabled } + for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration.webhook.queue.redrive_build_queue.enabled } queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id - policy = data.aws_iam_policy_document.deny_insecure_transport.json + policy = data.aws_iam_policy_document.deny_insecure_transport_build_dlq[each.key].json } diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index 529cf6b68a..b8481c54e1 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -1,6 +1,14 @@ -module "runner_stacks" { - source = "../runner-stack" - for_each = local.use_multi_runner_config_v2 ? local.translated_experimental.multi_runner_config : {} +moved { + from = module.runner_stacks + to = module.runner_configs +} + +module "runner_configs" { + source = "../runner-config" + for_each = { + for runner_key, runner_config in local.translated_experimental.multi_runner_config : + runner_key => runner_config if local.use_multi_runner_config_v2 + } aws_region = var.aws_region aws_partition = var.aws_partition @@ -14,17 +22,21 @@ module "runner_stacks" { github = merge(each.value.github, { app_parameters = local.github_app_parameters }) - queue = merge(each.value.queue, { - build = { - arn = aws_sqs_queue.queued_builds[each.key].arn - url = aws_sqs_queue.queued_builds[each.key].url + lambda = each.value.lambda + orchestration = { + webhook = each.value.orchestration.webhook == null ? null : { + runner = each.value.orchestration.webhook.runner + github = each.value.orchestration.webhook.github + queue = merge(each.value.orchestration.webhook.queue, { + build = { + arn = aws_sqs_queue.queued_builds[each.key].arn + url = aws_sqs_queue.queued_builds[each.key].url + } + }) + lambda = each.value.orchestration.webhook.lambda + job_retry = each.value.orchestration.webhook.job_retry } - }) - lambda = each.value.lambda - scale_up = each.value.lambda.scale_up - scale_down = each.value.lambda.scale_down - pool = each.value.lambda.pool - job_retry = each.value.job_retry + } ssm = each.value.ssm observability = each.value.observability compute_provider = each.value.compute_provider diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index fdb0c961f5..49adaa958c 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -1,6 +1,9 @@ module "runners" { - source = "../runners" - for_each = local.use_multi_runner_config_v2 ? {} : local.translated_experimental.multi_runner_config + source = "../runners" + for_each = { + for runner_key, runner_config in local.translated_experimental.multi_runner_config : + runner_key => runner_config if !local.use_multi_runner_config_v2 + } aws_region = var.aws_region aws_partition = var.aws_partition @@ -39,22 +42,22 @@ module "runners" { 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_organization_runners = each.value.orchestration.webhook.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.lambda.scale_up.job_queued_check_enabled + enable_job_queued_check = each.value.orchestration.webhook.lambda.scale_up.job_queued_check_enabled disable_runner_autoupdate = each.value.runner.auto_update_disabled enable_managed_runner_security_group = each.value.compute_provider.ec2.managed_security_group_enabled enable_runner_detailed_monitoring = each.value.compute_provider.ec2.detailed_monitoring_enabled - scale_down_schedule_expression = each.value.lambda.scale_down.schedule_expression - minimum_running_time_in_minutes = each.value.lambda.scale_down.minimum_running_time_in_minutes + scale_down_schedule_expression = each.value.orchestration.webhook.lambda.scale_down.schedule_expression + minimum_running_time_in_minutes = each.value.orchestration.webhook.lambda.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.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.lambda.scale_down.idle_config + runners_maximum_count = each.value.orchestration.webhook.runner.maximum_count + idle_config = each.value.orchestration.webhook.lambda.scale_down.idle_config enable_ssm_on_runners = each.value.compute_provider.ec2.ssm_enabled egress_rules = each.value.compute_provider.ec2.egress_rules runner_additional_security_group_ids = each.value.compute_provider.ec2.additional_security_group_ids @@ -66,18 +69,18 @@ module "runners" { 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 = local.translated_experimental.lambda.scale.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket - runners_lambda_s3_key = try(local.translated_experimental.lambda.scale.artifact.s3.key, null) - runners_lambda_s3_object_version = try(local.translated_experimental.lambda.scale.artifact.s3.object_version, null) + lambda_s3_bucket = local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + runners_lambda_s3_key = try(local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.key, null) + runners_lambda_s3_object_version = try(local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.object_version, null) lambda_runtime = each.value.lambda.runtime lambda_architecture = each.value.lambda.architecture - lambda_zip = local.translated_experimental.lambda.scale.artifact.zip - lambda_scale_up_memory_size = each.value.lambda.scale_up.memory_size - lambda_event_source_mapping_batch_size = each.value.queue.event_source_mapping.batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = each.value.queue.event_source_mapping.maximum_batching_window_in_seconds - lambda_timeout_scale_up = each.value.lambda.scale_up.timeout - lambda_scale_down_memory_size = each.value.lambda.scale_down.memory_size - lambda_timeout_scale_down = each.value.lambda.scale_down.timeout + lambda_zip = local.translated_experimental.orchestration.webhook.lambda.scale.artifact.zip + lambda_scale_up_memory_size = each.value.orchestration.webhook.lambda.scale_up.memory_size + lambda_event_source_mapping_batch_size = each.value.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = each.value.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds + lambda_timeout_scale_up = each.value.orchestration.webhook.lambda.scale_up.timeout + lambda_scale_down_memory_size = each.value.orchestration.webhook.lambda.scale_down.memory_size + lambda_timeout_scale_down = each.value.orchestration.webhook.lambda.scale_down.timeout lambda_subnet_ids = each.value.lambda.subnet_ids lambda_security_group_ids = each.value.lambda.security_group_ids lambda_tags = each.value.lambda.tags @@ -92,7 +95,7 @@ module "runners" { runner_name_prefix = each.value.runner.name_prefix parameter_store_tags = each.value.ssm.parameters.tags - scale_up_reserved_concurrent_executions = each.value.lambda.scale_up.reserved_concurrent_executions + scale_up_reserved_concurrent_executions = each.value.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions instance_profile_path = each.value.compute_provider.ec2.instance_profile_path role_path = each.value.runner.iam.path @@ -127,12 +130,12 @@ module "runners" { log_level = each.value.observability.logs.level - pool_config = each.value.lambda.pool.config - pool_lambda_timeout = each.value.lambda.pool.lambda.timeout - pool_lambda_memory_size = each.value.lambda.pool.lambda.memory_size - pool_runner_owner = each.value.lambda.pool.runner_owner - pool_include_busy_runners = each.value.lambda.pool.include_busy_runners - pool_lambda_reserved_concurrent_executions = each.value.lambda.pool.lambda.reserved_concurrent_executions + pool_config = each.value.orchestration.webhook.lambda.pool.config + pool_lambda_timeout = each.value.orchestration.webhook.lambda.pool.timeout + pool_lambda_memory_size = each.value.orchestration.webhook.lambda.pool.memory_size + pool_runner_owner = each.value.orchestration.webhook.lambda.pool.runner_owner + pool_include_busy_runners = each.value.orchestration.webhook.lambda.pool.include_busy_runners + pool_lambda_reserved_concurrent_executions = each.value.orchestration.webhook.lambda.pool.reserved_concurrent_executions associate_public_ipv4_address = each.value.compute_provider.ec2.associate_public_ipv4_address ssm_housekeeper = { @@ -144,13 +147,13 @@ module "runners" { } job_retry = { - enable = each.value.job_retry.enabled - delay_in_seconds = each.value.job_retry.delay_in_seconds - delay_backoff = each.value.job_retry.delay_backoff - lambda_memory_size = each.value.job_retry.lambda.memory_size - lambda_reserved_concurrent_executions = each.value.job_retry.lambda.reserved_concurrent_executions - lambda_timeout = each.value.job_retry.lambda.timeout - max_attempts = each.value.job_retry.max_attempts + enable = each.value.orchestration.webhook.job_retry.enabled + delay_in_seconds = each.value.orchestration.webhook.job_retry.delay_in_seconds + delay_backoff = each.value.orchestration.webhook.job_retry.delay_backoff + lambda_memory_size = each.value.orchestration.webhook.job_retry.lambda.memory_size + lambda_reserved_concurrent_executions = each.value.orchestration.webhook.job_retry.lambda.reserved_concurrent_executions + lambda_timeout = each.value.orchestration.webhook.job_retry.lambda.timeout + max_attempts = each.value.orchestration.webhook.job_retry.max_attempts } metrics = { diff --git a/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl b/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl index 0d8fd6435b..1a9a59df1d 100644 --- a/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl +++ b/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl @@ -56,12 +56,12 @@ run "computed_lane_values_keep_binary_syncer_instances_plannable" { } assert { - condition = output.runner_stack_keys == ["linux"] - error_message = "Apply-time values inside a statically keyed runner lane must not make binary-syncer module instances unknown." + condition = output.runner_config_keys == ["linux"] + error_message = "Apply-time values inside a statically keyed runner configuration must not make binary-syncer module instances unknown." } assert { condition = output.binaries_syncer_keys == [] - error_message = "A lane with the binary syncer disabled must not create a binary-syncer module instance." + error_message = "A runner configuration with the binary syncer disabled must not create a binary-syncer module instance." } } diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 5695a15af3..4f972ded54 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,26 +2,26 @@ ## 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 | -|------|--------|---------| +| ---- | ------ | ------- | | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs @@ -31,7 +31,7 @@ No inputs. ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | -| [runner\_stack\_keys](#output\_runner\_stack\_keys) | n/a | +| [runner\_config\_keys](#output\_runner\_config\_keys) | n/a | \ No newline at end of file diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf index cd6062d497..7ac79ec4ef 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -1,5 +1,5 @@ -# Keep the runner lane key, provider selection, and optional KMS scalar -# caller-known while passing apply-time ARNs through the experimental configuration. +# Keep the runner-configuration key, provider selection, and optional KMS scalar +# caller-known while passing apply-time ARNs through the configuration. resource "random_id" "managed_policy" { byte_length = 4 } @@ -9,8 +9,16 @@ module "multi_runner" { aws_region = "eu-west-1" prefix = "computed-inputs" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/generated-${random_id.managed_policy.hex}" + 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" @@ -31,17 +39,33 @@ module "multi_runner" { bucket = "nested-lambda-artifacts" } } - scale = { - artifact = { - s3 = { - key = "nested-runners.zip" + } + + orchestration = { + webhook = { + queue = { + encryption = { + kms_data_key_reuse_period_seconds = 300 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/generated-${random_id.managed_policy.hex}" + sqs_managed_sse_enabled = null } } - } - webhook = { - artifact = { - s3 = { - key = "webhook.zip" + + lambda = { + scale = { + artifact = { + s3 = { + key = "nested-runners.zip" + } + } + } + + webhook = { + artifact = { + s3 = { + key = "webhook.zip" + } + } } } } @@ -56,20 +80,39 @@ module "multi_runner" { ssm = { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/generated-${random_id.managed_policy.hex}" + housekeeper = { + lambda = { + artifact = { + s3 = { + key = "nested-ssm-housekeeper.zip" + } + } + } + } } multi_runner_config = { linux = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" iam = { managed_policy_arns = { generated = "arn:aws:iam::123456789012:policy/generated-${random_id.managed_policy.hex}" } } } + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -78,15 +121,12 @@ module "multi_runner" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } } -output "runner_stack_keys" { +output "runner_config_keys" { value = keys(module.multi_runner.runners_map_v2) } diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index e2ff1eaa91..9b9ce6dce9 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -17,6 +17,14 @@ 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" @@ -28,17 +36,6 @@ variables { run "empty_runner_configurations_return_empty_output_maps" { command = plan - variables { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - - github_app = { - id = "123456" - key_base64 = "dGVzdA==" - webhook_secret = "test-secret" - } - } - 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." @@ -48,9 +45,21 @@ run "empty_runner_configurations_return_empty_output_maps" { condition = ( length(local.raw_translated_experimental.multi_runner_config) == 0 && length(local.translated_experimental.multi_runner_config) == 0 - && length(module.runner_stacks) == 0 + && length(local.webhook_runner_config) == 0 + && length(local.runner_matcher_config) == 0 + && length(module.runner_configs) == 0 + ) + error_message = "An empty stable and experimental configuration must translate to an empty raw runner-configuration map without selecting a v2 runner configuration." + } + + assert { + condition = ( + local.github_app_parameters.webhook_secret != null + && module.ssm.parameters.github_app_webhook_secret != null + && output.ssm_parameters.webhook_secret != null + && output.webhook != null ) - error_message = "An empty stable and experimental configuration must translate to an empty raw lane map without selecting a v2 runner stack." + error_message = "Stable v1 must retain its shared webhook and webhook-secret parameter even when multi_runner_config is empty." } } @@ -58,15 +67,6 @@ run "stable_v1_keeps_legacy_runner_module" { command = plan variables { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - - github_app = { - id = "123456" - key_base64 = "dGVzdA==" - webhook_secret = "test-secret" - } - tags = { StableGlobal = "global" Precedence = "global" @@ -212,9 +212,16 @@ run "stable_v1_keeps_legacy_runner_module" { tags = { ExperimentalLambda = "ignored" } + } + + orchestration = { webhook = { - memory_size = 896 - timeout = 90 + lambda = { + webhook = { + memory_size = 896 + timeout = 90 + } + } } } github = { @@ -311,9 +318,8 @@ run "stable_v1_keeps_legacy_runner_module" { "roles", "runner", "github", - "webhook", "lambda", - "queue", + "orchestration", "ssm", "observability", "compute_provider", @@ -326,17 +332,42 @@ run "stable_v1_keeps_legacy_runner_module" { "enterprise_server", "user_agent", ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"])) == toset([ + && toset(keys(local.raw_translated_experimental.lambda)) == toset([ + "artifact", + "runtime", + "architecture", + "principals", + "subnet_ids", + "security_group_ids", "tags", + "role", + ]) + && toset(keys(local.raw_translated_experimental.orchestration)) == toset([ + "webhook", + ]) + && toset(keys(local.raw_translated_experimental.orchestration.webhook)) == toset([ + "queue_selection_strategy", + "eventbridge", + "matcher_config_parameter_store_tier", "runner", - "github", "lambda", "queue", - "job_retry", + ]) + && toset(keys(local.raw_translated_experimental.orchestration.webhook.lambda)) == toset([ + "scale", + "scale_up", + "scale_down", + "webhook", + "pool", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"])) == toset([ + "tags", + "runner", + "lambda", + "orchestration", "ssm", "observability", "compute_provider", - "matcherConfig", ]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].lambda)) == toset([ "runtime", @@ -345,18 +376,31 @@ run "stable_v1_keeps_legacy_runner_module" { "security_group_ids", "tags", "role", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration)) == toset([ + "webhook", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook)) == toset([ + "runner", + "github", + "lambda", + "queue", + "job_retry", + "matcherConfig", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda)) == toset([ "scale_up", "scale_down", "pool", ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].queue)) == toset([ + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue)) == toset([ "delay_webhook_event", "job_queue_retention_in_seconds", "visibility_timeout_seconds", "redrive_build_queue", "tags", ]) - && toset(keys(local.raw_translated_experimental.queue)) == toset([ + && toset(keys(local.raw_translated_experimental.orchestration.webhook.queue)) == toset([ "delay_webhook_event", "job_queue_retention_in_seconds", "visibility_timeout_seconds", @@ -367,9 +411,11 @@ run "stable_v1_keeps_legacy_runner_module" { && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["ec2"]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].ssm), "kms_key_id") + && !contains(keys(local.raw_translated_experimental.runner), "maximum_count") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "maximum_count") && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"]), "scale_up") ) - error_message = "Stable v1 must translate into the exact raw experimental schema before defaults, queue event-source mappings, binary artifacts, or runner-stack component shapes are resolved." + error_message = "Stable v1 must translate into the exact raw experimental schema before defaults, queue event-source mappings, binary artifacts, or runner-config component shapes are resolved." } assert { @@ -380,7 +426,7 @@ run "stable_v1_keeps_legacy_runner_module" { && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3)) == toset(["arn", "id", "key"]) ) - error_message = "Stable translation must discover binary-syncer lanes from the base object, then enrich the final canonical lane with its resolved S3 distribution." + error_message = "Stable translation must discover binary-syncer runner configurations from the base object, then enrich the final canonical configuration with its resolved S3 distribution." } assert { @@ -394,29 +440,29 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.github.enterprise_server.url == var.ghes_url && local.raw_translated_experimental.github.enterprise_server.ssl_verify == var.ghes_ssl_verify && local.raw_translated_experimental.github.user_agent == var.user_agent - && local.raw_translated_experimental.webhook.queue_selection_strategy == var.queue_selection_strategy - && local.raw_translated_experimental.webhook.eventbridge == var.eventbridge - && local.raw_translated_experimental.webhook.matcher_config_parameter_store_tier == var.matcher_config_parameter_store_tier - && local.raw_translated_experimental.lambda.scale.artifact.zip == null + && local.raw_translated_experimental.orchestration.webhook.queue_selection_strategy == var.queue_selection_strategy + && local.raw_translated_experimental.orchestration.webhook.eventbridge == var.eventbridge + && local.raw_translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == var.matcher_config_parameter_store_tier + && local.raw_translated_experimental.orchestration.webhook.lambda.scale.artifact.zip == null && local.raw_translated_experimental.lambda.artifact.s3.bucket == var.lambda_s3_bucket - && local.raw_translated_experimental.lambda.scale.artifact.s3.key == var.runners_lambda_s3_key - && local.raw_translated_experimental.lambda.scale.artifact.s3.object_version == var.runners_lambda_s3_object_version + && local.raw_translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.key == var.runners_lambda_s3_key + && local.raw_translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.object_version == var.runners_lambda_s3_object_version && local.raw_translated_experimental.lambda.runtime == var.lambda_runtime && local.raw_translated_experimental.lambda.architecture == var.lambda_architecture && local.raw_translated_experimental.lambda.principals == var.lambda_principals && local.raw_translated_experimental.lambda.subnet_ids == var.lambda_subnet_ids && local.raw_translated_experimental.lambda.security_group_ids == var.lambda_security_group_ids && local.raw_translated_experimental.lambda.tags == var.lambda_tags - && local.raw_translated_experimental.lambda.scale_up.event_source_mapping.batch_size == var.lambda_event_source_mapping_batch_size - && local.raw_translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == var.lambda_event_source_mapping_maximum_batching_window_in_seconds - && local.raw_translated_experimental.lambda.webhook.artifact.zip == null - && local.raw_translated_experimental.lambda.webhook.artifact.s3.key == var.webhook_lambda_s3_key - && local.raw_translated_experimental.lambda.webhook.artifact.s3.object_version == var.webhook_lambda_s3_object_version - && local.raw_translated_experimental.lambda.webhook.api_gateway_access_log_settings == var.webhook_lambda_apigateway_access_log_settings - && local.raw_translated_experimental.lambda.webhook.memory_size == var.webhook_lambda_memory_size - && local.raw_translated_experimental.lambda.webhook.timeout == var.webhook_lambda_timeout - && local.raw_translated_experimental.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout - && local.raw_translated_experimental.queue.encryption == var.queue_encryption + && local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == var.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == var.lambda_event_source_mapping_maximum_batching_window_in_seconds + && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip == null + && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.key == var.webhook_lambda_s3_key + && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.object_version == var.webhook_lambda_s3_object_version + && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings == var.webhook_lambda_apigateway_access_log_settings + && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.memory_size == var.webhook_lambda_memory_size + && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.timeout == var.webhook_lambda_timeout + && local.raw_translated_experimental.orchestration.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && local.raw_translated_experimental.orchestration.webhook.queue.encryption == var.queue_encryption && local.raw_translated_experimental.ssm.paths.root == "/legacy-root/github-actions" && local.raw_translated_experimental.ssm.paths.app == var.ssm_paths.app && local.raw_translated_experimental.ssm.paths.webhook == var.ssm_paths.webhook @@ -435,6 +481,11 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.observability.metrics.metric.enable_job_retry == var.metrics.metric.enable_job_retry && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination_warning == var.metrics.metric.enable_spot_termination_warning + && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.zip == null + && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3.key == var.runners_lambda_s3_key + && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.key == var.runners_lambda_s3_key + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version && local.raw_translated_experimental.compute_provider.ec2.vpc_id == var.vpc_id && local.raw_translated_experimental.compute_provider.ec2.subnet_ids == var.subnet_ids && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.enabled == var.enable_ami_housekeeper @@ -466,15 +517,15 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == var.state_event_rule_binaries_syncer && local.raw_translated_experimental.multi_runner_config["linux"].runner.os == var.multi_runner_config["linux"].runner_config.runner_os && local.raw_translated_experimental.multi_runner_config["linux"].runner.architecture == var.multi_runner_config["linux"].runner_config.runner_architecture - && local.raw_translated_experimental.multi_runner_config["linux"].runner.maximum_count == var.multi_runner_config["linux"].runner_config.runners_maximum_count - && local.raw_translated_experimental.multi_runner_config["linux"].github.organization_runners == var.multi_runner_config["linux"].runner_config.enable_organization_runners - && local.raw_translated_experimental.multi_runner_config["linux"].lambda.scale_up.event_source_mapping.batch_size == var.multi_runner_config["linux"].runner_config.lambda_event_source_mapping_batch_size - && local.raw_translated_experimental.multi_runner_config["linux"].queue.delay_webhook_event == var.multi_runner_config["linux"].runner_config.delay_webhook_event - && local.raw_translated_experimental.multi_runner_config["linux"].queue.job_queue_retention_in_seconds == var.multi_runner_config["linux"].runner_config.job_queue_retention_in_seconds - && local.raw_translated_experimental.multi_runner_config["linux"].queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout - && local.raw_translated_experimental.multi_runner_config["linux"].queue.redrive_build_queue == var.multi_runner_config["linux"].redrive_build_queue + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.maximum_count == var.multi_runner_config["linux"].runner_config.runners_maximum_count + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.github.organization_runners == var.multi_runner_config["linux"].runner_config.enable_organization_runners + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == var.multi_runner_config["linux"].runner_config.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.delay_webhook_event == var.multi_runner_config["linux"].runner_config.delay_webhook_event + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.job_queue_retention_in_seconds == var.multi_runner_config["linux"].runner_config.job_queue_retention_in_seconds + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.redrive_build_queue == var.multi_runner_config["linux"].redrive_build_queue && local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled - && local.raw_translated_experimental.multi_runner_config["linux"].matcherConfig == var.multi_runner_config["linux"].matcherConfig + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.matcherConfig == var.multi_runner_config["linux"].matcherConfig ) error_message = "Stable v1 flat and per-runner inputs must populate every raw translation family while conflicting experimental globals remain inactive." } @@ -498,7 +549,7 @@ run "stable_v1_keeps_legacy_runner_module" { !local.use_multi_runner_config_v2 && toset(keys(local.raw_translated_experimental.multi_runner_config)) == toset(["linux"]) && toset(keys(local.translated_experimental.multi_runner_config)) == toset(["linux"]) - && length(module.runner_stacks) == 0 + && length(module.runner_configs) == 0 && keys(module.runners) == ["linux"] ) error_message = "Stable multi_runner_config entries must keep the original runner configuration and remain isolated from v2." @@ -524,13 +575,13 @@ run "stable_v1_keeps_legacy_runner_module" { condition = ( contains(keys(local.translated_experimental.multi_runner_config["linux"]), "compute_provider") && !contains(keys(local.translated_experimental.multi_runner_config["linux"]), "runner_config") - && local.translated_experimental.multi_runner_config["linux"].github.organization_runners + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.github.organization_runners ) - error_message = "Stable module inputs must use the canonical translated lane while retaining stable module.runners ownership." + error_message = "Stable module inputs must use the canonical translated runner configuration while retaining stable module.runners ownership." } assert { - condition = keys(module.runners) == ["linux"] && length(module.runner_stacks) == 0 + condition = keys(module.runners) == ["linux"] && length(module.runner_configs) == 0 error_message = "Stable multi_runner_config entries must retain the historical module.runners address." } @@ -736,58 +787,6 @@ run "stable_v1_keeps_legacy_runner_module" { } } -run "stable_v1_requires_flat_github_app" { - command = plan - - plan_options { - target = [terraform_data.validate_experimental] - } - - variables { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - } - - expect_failures = [terraform_data.validate_experimental] -} - -run "stable_v1_rejects_incomplete_flat_github_app" { - command = plan - - plan_options { - target = [terraform_data.validate_experimental] - } - - variables { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - - github_app = { - id = "123456" - } - } - - expect_failures = [terraform_data.validate_experimental] -} - -run "stable_v1_requires_flat_network_inputs" { - command = plan - - plan_options { - target = [terraform_data.validate_experimental] - } - - variables { - github_app = { - id = "123456" - key_base64 = "dGVzdA==" - webhook_secret = "test-secret" - } - } - - expect_failures = [terraform_data.validate_experimental] -} - run "experimental_v2_routes_through_provider_stack" { command = plan @@ -979,15 +978,30 @@ run "experimental_v2_routes_through_provider_stack" { }] } - lambda = { - scale = { - artifact = { - zip = "README.md" + orchestration = { + webhook = { + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } - webhook = { - artifact = { - zip = "README.md" + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } } } } @@ -1009,9 +1023,8 @@ run "experimental_v2_routes_through_provider_stack" { multi_runner_config = { linux = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" hooks = { job_started = "/opt/actions/job-started.sh" } @@ -1021,24 +1034,53 @@ run "experimental_v2_routes_through_provider_stack" { } } } - github = { - organization_runners = true - } - lambda = { - scale_down = { - idle_config = [{ - cron = "* * * * *" - timeZone = "UTC" - idleCount = 1 - }] - } - pool = { - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + github = { + organization_runners = true + } + + lambda = { + scale_down = { + idle_config = [{ + cron = "* * * * *" + timeZone = "UTC" + idleCount = 1 + }] + } + + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + enableDynamicLabels = true + awsDynamicLabelsPolicy = { + blocked_keys = ["image-id"] + restricted_keys = { + "instance-type" = { + allowed = ["m5.*", "c5.*"] + denied = ["*.metal"] + } + "ebs-volume-size" = { + max = 200 + } + } + } + } } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -1047,22 +1089,6 @@ run "experimental_v2_routes_through_provider_stack" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - enableDynamicLabels = true - awsDynamicLabelsPolicy = { - blocked_keys = ["image-id"] - restricted_keys = { - "instance-type" = { - allowed = ["m5.*", "c5.*"] - denied = ["*.metal"] - } - "ebs-volume-size" = { - max = 200 - } - } - } - } } } } @@ -1090,7 +1116,7 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = toset(keys(module.runner_stacks)) == toset(["linux"]) && toset(keys(local.translated_experimental.multi_runner_config)) == toset(["linux"]) + condition = toset(keys(module.runner_configs)) == toset(["linux"]) && toset(keys(local.translated_experimental.multi_runner_config)) == toset(["linux"]) error_message = "Experimental multi_runner_config entries must remain isolated in the v2 configuration map." } @@ -1101,12 +1127,12 @@ run "experimental_v2_routes_through_provider_stack" { && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null && keys(output.binaries_syncer_map) == ["linux_x64"] ) - error_message = "V2 binary discovery must use the pure base lane, enrich the final canonical lane with S3, and create the corresponding shared syncer resources." + error_message = "V2 binary discovery must use the pure base runner configuration, enrich the final canonical configuration with S3, and create the corresponding shared syncer resources." } assert { - condition = toset(flatten(local.translated_experimental.multi_runner_config["linux"].matcherConfig.labelMatchers)) == toset(["self-hosted", "linux", "x64"]) - error_message = "The canonical experimental lane must retain labels declared by its matcher configuration for the runner adapter." + condition = toset(flatten(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.matcherConfig.labelMatchers)) == toset(["self-hosted", "linux", "x64"]) + error_message = "The canonical experimental runner configuration must retain labels declared by its matcher configuration for the runner adapter." } assert { @@ -1120,8 +1146,8 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = length(module.runners) == 0 && keys(module.runner_stacks) == ["linux"] - error_message = "A non-empty experimental.multi_runner_config map must take priority over stable multi_runner_config and dispatch only through module.runner_stacks." + condition = length(module.runners) == 0 && keys(module.runner_configs) == ["linux"] + error_message = "A non-empty experimental.multi_runner_config map must take priority over stable multi_runner_config and dispatch only through module.runner_configs." } assert { @@ -1141,19 +1167,48 @@ run "experimental_v2_routes_through_provider_stack" { && local.translated_experimental.multi_runner_config["linux"].runner.hooks.job_completed == "" && local.translated_experimental.multi_runner_config["linux"].runner.iam.path == null && local.translated_experimental.multi_runner_config["linux"].runner.iam.permissions_boundary == null + && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "maximum_count") + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.maximum_count == 2 + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" + && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" ) - error_message = "Experimental v2 runner, tag, and role defaults must be self-contained and must not inherit deliberately different stable inputs." + error_message = "Experimental v2 common runner defaults must stay provider-neutral while webhook-owned capacity reaches scale-up and pool without stable-input fallback." } assert { condition = ( - local.translated_experimental.lambda.scale.artifact.zip == "README.md" - && local.translated_experimental.lambda.scale.artifact.s3 == null + local.translated_experimental.orchestration.webhook.lambda.scale.artifact.zip == "README.md" + && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3 == null && local.translated_experimental.lambda.artifact.s3.bucket == null - && local.translated_experimental.multi_runner_config["linux"].lambda.zip == "README.md" - && local.translated_experimental.multi_runner_config["linux"].lambda.s3.bucket == null - && local.translated_experimental.multi_runner_config["linux"].lambda.s3.key == null - && local.translated_experimental.multi_runner_config["linux"].lambda.s3.object_version == null + && local.translated_experimental.multi_runner_config["linux"].lambda.artifact.s3.bucket == null + && toset(keys(local.translated_experimental.multi_runner_config["linux"].lambda)) == toset([ + "artifact", + "runtime", + "architecture", + "subnet_ids", + "security_group_ids", + "tags", + "role", + "principals", + ]) + && !contains(keys(local.translated_experimental.multi_runner_config["linux"].lambda), "zip") + && !contains(keys(local.translated_experimental.multi_runner_config["linux"].lambda), "s3") + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.artifact.zip == "README.md" + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.artifact.s3 == null + && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda)) == toset([ + "scale", + "scale_up", + "scale_down", + "pool", + ]) + && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue)) == toset([ + "delay_webhook_event", + "job_queue_retention_in_seconds", + "visibility_timeout_seconds", + "redrive_build_queue", + "tags", + "kms_key_id", + ]) && length(local.translated_experimental.lambda.principals) == 0 && local.translated_experimental.multi_runner_config["linux"].lambda.runtime == "nodejs24.x" && local.translated_experimental.multi_runner_config["linux"].lambda.architecture == "arm64" @@ -1162,46 +1217,46 @@ run "experimental_v2_routes_through_provider_stack" { && length(local.translated_experimental.multi_runner_config["linux"].lambda.tags) == 0 && local.translated_experimental.multi_runner_config["linux"].lambda.role.path == null && local.translated_experimental.multi_runner_config["linux"].lambda.role.permissions_boundary == null - && module.runner_stacks["linux"].scale_up.lambda.runtime == "nodejs24.x" - && module.runner_stacks["linux"].scale_up.lambda.filename == "README.md" - && module.runner_stacks["linux"].scale_up.lambda.s3_bucket == null - && module.runner_stacks["linux"].scale_up.lambda.memory_size == 512 - && module.runner_stacks["linux"].scale_up.lambda.timeout == 30 - && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.reserved_concurrent_executions == 1 - && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.job_queued_check_enabled == null - && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.event_source_mapping.batch_size == 10 - && local.translated_experimental.multi_runner_config["linux"].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 - && module.runner_stacks["linux"].scale_down.lambda.memory_size == 512 - && module.runner_stacks["linux"].scale_down.lambda.timeout == 60 - && local.translated_experimental.multi_runner_config["linux"].lambda.scale_down.schedule_expression == "cron(*/5 * * * ? *)" - && local.translated_experimental.multi_runner_config["linux"].lambda.scale_down.minimum_running_time_in_minutes == null - && module.runner_stacks["linux"].pool.lambda.memory_size == 512 - && module.runner_stacks["linux"].pool.lambda.timeout == 60 - && local.translated_experimental.multi_runner_config["linux"].lambda.pool.reserved_concurrent_executions == 1 - && !local.translated_experimental.multi_runner_config["linux"].lambda.pool.include_busy_runners - && local.translated_experimental.multi_runner_config["linux"].lambda.pool.runner_owner == null + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.runtime == "nodejs24.x" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.filename == "README.md" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.s3_bucket == null + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.memory_size == 512 + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.timeout == 30 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.reserved_concurrent_executions == 1 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.job_queued_check_enabled == null + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 10 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.memory_size == 512 + && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_down.schedule_expression == "cron(*/5 * * * ? *)" + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes == null + && module.runner_configs["linux"].orchestration.webhook.pool.lambda.memory_size == 512 + && module.runner_configs["linux"].orchestration.webhook.pool.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.pool.reserved_concurrent_executions == 1 + && !local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.pool.include_busy_runners + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.pool.runner_owner == null ) - error_message = "Experimental v2 runner-stack Lambda components must inherit the concrete nested defaults and ignore every corresponding stable Lambda input." + error_message = "Experimental v2 runner-config Lambda components must inherit the concrete nested defaults and ignore every corresponding stable Lambda input." } assert { condition = ( - local.translated_experimental.multi_runner_config["linux"].queue.delay_webhook_event == 30 - && local.translated_experimental.multi_runner_config["linux"].queue.job_queue_retention_in_seconds == 86400 - && local.translated_experimental.multi_runner_config["linux"].queue.visibility_timeout_seconds == 180 - && !local.translated_experimental.multi_runner_config["linux"].queue.redrive_build_queue.enabled - && length(local.translated_experimental.multi_runner_config["linux"].queue.tags) == 0 - && local.translated_experimental.queue.encryption == var.experimental.queue.encryption - && local.translated_experimental.queue.encryption.sqs_managed_sse_enabled - && local.translated_experimental.queue.encryption.kms_master_key_id == null - && local.translated_experimental.queue.encryption.kms_data_key_reuse_period_seconds == null + local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.delay_webhook_event == 30 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.job_queue_retention_in_seconds == 86400 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.visibility_timeout_seconds == 180 + && !local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.redrive_build_queue.enabled + && length(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.tags) == 0 + && local.translated_experimental.orchestration.webhook.queue.encryption == var.experimental.orchestration.webhook.queue.encryption + && local.translated_experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled + && local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id == null + && local.translated_experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds == null && aws_sqs_queue.queued_builds["linux"].delay_seconds == 30 && aws_sqs_queue.queued_builds["linux"].message_retention_seconds == 86400 && aws_sqs_queue.queued_builds["linux"].visibility_timeout_seconds == 180 && aws_sqs_queue.queued_builds["linux"].sqs_managed_sse_enabled && aws_sqs_queue.queued_builds["linux"].kms_master_key_id == null ) - error_message = "Experimental v2 queues must use concrete nested defaults, including a six-times-Lambda visibility timeout and SQS-managed encryption, instead of flat queue inputs." + error_message = "Experimental v2 queues must use orchestration.webhook.queue defaults, including a six-times-Lambda visibility timeout and SQS-managed encryption, instead of stable flat inputs." } assert { @@ -1209,31 +1264,33 @@ run "experimental_v2_routes_through_provider_stack" { local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps && length(local.translated_experimental.github.repository_white_list) == 0 + && !contains(keys(local.translated_experimental), "enterprise_server") + && !contains(keys(local.translated_experimental), "user_agent") && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server && local.translated_experimental.github.user_agent == var.experimental.github.user_agent && local.translated_experimental.github.enterprise_server.url == null && local.translated_experimental.github.enterprise_server.ssl_verify && local.translated_experimental.github.user_agent == "github-aws-runners" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["GHES_URL"] == null - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "1" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" - && module.runner_stacks["linux"].scale_down.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" - && module.runner_stacks["linux"].pool.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == null + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "1" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" ) - error_message = "V2 runner stacks must use concrete nested GitHub connection defaults rather than deliberately different flat GHES and user-agent inputs." + error_message = "V2 runner configurations must use concrete nested GitHub connection defaults rather than deliberately different flat GHES and user-agent inputs." } assert { condition = ( - local.translated_experimental.webhook.queue_selection_strategy == "first" - && local.translated_experimental.webhook.eventbridge.enable - && length(local.translated_experimental.webhook.eventbridge.accept_events) == 0 - && local.translated_experimental.webhook.matcher_config_parameter_store_tier == "Standard" - && local.translated_experimental.lambda.webhook.artifact.zip == "README.md" - && local.translated_experimental.lambda.webhook.artifact.s3 == null - && local.translated_experimental.lambda.webhook.api_gateway_access_log_settings == null - && local.translated_experimental.lambda.scale_up.event_source_mapping.batch_size == 10 - && local.translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + local.translated_experimental.orchestration.webhook.queue_selection_strategy == "first" + && local.translated_experimental.orchestration.webhook.eventbridge.enable + && length(local.translated_experimental.orchestration.webhook.eventbridge.accept_events) == 0 + && local.translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == "Standard" + && local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip == "README.md" + && local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3 == null + && local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings == null + && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 10 + && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 ) error_message = "V2 webhook controls, the explicitly nested local artifact, API access-log defaults, and scale-up event-source mappings must avoid flat-input fallback." } @@ -1269,6 +1326,8 @@ run "experimental_v2_routes_through_provider_stack" { && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.state == "ENABLED" && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.memory_size == 512 && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.zip == "README.md" + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3 == null && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.config.tokenPath == null && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.config.minimumDaysOld == 1 && !local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.config.dryRun @@ -1278,22 +1337,22 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "INFO" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GitHub Runners" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/github-action-runners/github-actions/linux/runners/tokens" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/github-action-runners/github-actions/linux/runners/config" - && module.runner_stacks["linux"].scale_up.log_group.retention_in_days == 180 - && module.runner_stacks["linux"].scale_up.log_group.kms_key_id == null - && module.runner_stacks["linux"].scale_up.log_group.log_group_class == "STANDARD" - && length(module.runner_stacks["linux"].scale_up.lambda.tracing_config) == 0 - && jsondecode(module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) == [{ + module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "INFO" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GitHub Runners" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/github-action-runners/github-actions/linux/runners/tokens" + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/github-action-runners/github-actions/linux/runners/config" + && module.runner_configs["linux"].orchestration.webhook.scale_up.log_group.retention_in_days == 180 + && module.runner_configs["linux"].orchestration.webhook.scale_up.log_group.kms_key_id == null + && module.runner_configs["linux"].orchestration.webhook.scale_up.log_group.log_group_class == "STANDARD" + && length(module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.tracing_config) == 0 + && jsondecode(module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) == [{ Key = "ghr:environment" Value = "github-actions" }] ) - error_message = "Concrete experimental observability and SSM defaults must reach runner-stack resources without stable-input leakage." + error_message = "Concrete experimental observability and SSM defaults must reach runner-config resources without stable-input leakage." } assert { @@ -1404,21 +1463,21 @@ run "experimental_v2_routes_through_provider_stack" { && length(module.instance_termination_watcher) == 0 && output.instance_termination_watcher == null ) - error_message = "V2 EC2 lanes must use nested network inputs and concrete nested EC2 defaults instead of corresponding stable inputs." + error_message = "V2 EC2 runner configurations must use nested network inputs and concrete nested EC2 defaults instead of corresponding stable inputs." } assert { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps - && var.github_app == null + && local.translated_experimental.github.app == var.github_app && local.translated_experimental.github.additional_apps == var.additional_github_apps && length(local.github_app_parameters.id) == 2 && length(module.ssm.additional_app_parameters) == 1 && module.ssm.additional_app_parameters[0].id.name == "/github-runner/additional-app-id" - && module.runner_stacks["linux"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == join(":", [for p in local.github_app_parameters.id : p.name]) - && module.runner_stacks["linux"].scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) - && module.runner_stacks["linux"].pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) + && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == join(":", [for p in local.github_app_parameters.id : p.name]) + && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) + && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) ) error_message = "Experimental v2 control-plane Lambdas must preserve the complete multi-app parameter lists from the shared multi-runner configuration." } @@ -1443,22 +1502,26 @@ run "experimental_v2_routes_through_provider_stack" { [ "provider", "runner", + "orchestration", "scale_up", "scale_down", "pool", ] ) - error_message = "Experimental v2 runners_map_v2 entries must group common and provider resources by owner." + error_message = "Experimental v2 runners_map_v2 entries must add canonical orchestration while retaining the existing component aliases." } assert { condition = ( 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"]) + && toset(keys(output.runners_map_v2["linux"].orchestration.webhook.scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map_v2["linux"].orchestration.webhook.scale_down)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map_v2["linux"].orchestration.webhook.pool)) == toset(["lambda", "log_group", "role"]) + && output.runners_map_v2["linux"].scale_up == output.runners_map_v2["linux"].orchestration.webhook.scale_up + && output.runners_map_v2["linux"].scale_down == output.runners_map_v2["linux"].orchestration.webhook.scale_down + && output.runners_map_v2["linux"].pool == output.runners_map_v2["linux"].orchestration.webhook.pool ) - error_message = "Experimental v2 common resources must use the nested runner, scale-up, scale-down, and pool contracts." + error_message = "Experimental v2 common resources must use the nested runner and orchestration contracts while preserving compatibility output aliases." } assert { @@ -1485,7 +1548,7 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = local.runner_config_by_provider.ec2["linux"].lambda.scale_down.idle_config[0].idleCount == 1 + condition = local.runner_config_by_provider.ec2["linux"].orchestration.webhook.lambda.scale_down.idle_config[0].idleCount == 1 error_message = "Provider-neutral idle configuration must remain in the common runner contract." } @@ -1503,7 +1566,96 @@ run "experimental_v2_routes_through_provider_stack" { } } -run "experimental_v2_applies_global_defaults_and_lane_overrides" { +run "experimental_v2_rejects_missing_orchestration_block" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-orchestration" + subnet_ids = ["subnet-missing-orchestration"] + } + } + multi_runner_config = { + invalid = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration = {} + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_requires_webhook_maximum_count" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-webhook-maximum" + subnet_ids = ["subnet-missing-webhook-maximum"] + } + } + multi_runner_config = { + invalid = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_applies_global_defaults_and_configuration_overrides" { command = plan variables { @@ -1554,10 +1706,9 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { } runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 - group_name = "global-group" + os = "linux" + architecture = "x64" + group_name = "global-group" } github = { @@ -1574,87 +1725,110 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { user_agent = "experimental-runner-user-agent" } - webhook = { - queue_selection_strategy = "all" - eventbridge = { - enable = false - accept_events = ["workflow_job"] - } - matcher_config_parameter_store_tier = "Advanced" - } - lambda = { artifact = { s3 = { bucket = "experimental-lambda-artifacts" } } - scale = { - artifact = { - s3 = { - key = "nested-runners.zip" - object_version = "nested-runners-version" - } - } - } runtime = "nodejs22.x" principals = [{ type = "Service" identifiers = ["states.amazonaws.com"] }] - scale_up = { - memory_size = 768 - timeout = 40 - event_source_mapping = { - batch_size = 25 - } - } - scale_down = { - timeout = 75 - } + } + + orchestration = { webhook = { - artifact = { - s3 = { - key = "nested-webhook.zip" - object_version = "nested-webhook-version" - } + runner = { + maximum_count = 2 } - api_gateway_access_log_settings = { - destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" - format = "$context.requestId $context.status" + + queue_selection_strategy = "all" + eventbridge = { + enable = false + accept_events = ["workflow_job"] } - memory_size = 384 - } - pool = { - memory_size = 384 - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] - } - } + matcher_config_parameter_store_tier = "Advanced" - queue = { - delay_webhook_event = 23 - job_queue_retention_in_seconds = 172800 - visibility_timeout_seconds = 240 - redrive_build_queue = { - enabled = true - maxReceiveCount = 7 - } - tags = { - GlobalQueue = "global" - Precedence = "global" - } - encryption = { - kms_data_key_reuse_period_seconds = 900 - kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" - sqs_managed_sse_enabled = null + lambda = { + scale = { + artifact = { + s3 = { + key = "nested-runners.zip" + object_version = "nested-runners-version" + } + } + } + + scale_up = { + memory_size = 768 + timeout = 40 + event_source_mapping = { + batch_size = 25 + } + } + + scale_down = { + timeout = 75 + } + + webhook = { + artifact = { + s3 = { + key = "nested-webhook.zip" + object_version = "nested-webhook-version" + } + } + api_gateway_access_log_settings = { + destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" + format = "$context.requestId $context.status" + } + memory_size = 384 + } + + pool = { + memory_size = 384 + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + } + + queue = { + delay_webhook_event = 23 + job_queue_retention_in_seconds = 172800 + visibility_timeout_seconds = 240 + redrive_build_queue = { + enabled = true + maxReceiveCount = 7 + } + tags = { + GlobalQueue = "global" + Precedence = "global" + } + encryption = { + kms_data_key_reuse_period_seconds = 900 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" + sqs_managed_sse_enabled = null + } + } } } ssm = { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-queue" + housekeeper = { + lambda = { + artifact = { + s3 = { + key = "global-ssm-housekeeper.zip" + object_version = "global-ssm-housekeeper-version" + } + } + } + } } compute_provider = { @@ -1740,34 +1914,63 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { multi_runner_config = { resolved = { runner = { - group_name = "lane-group" - maximum_count = 4 + group_name = "lane-group" } + lambda = { role = { path = "/lane-lambda/" } - scale_up = { - memory_size = 896 - event_source_mapping = { - batch_size = 50 + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } } } - pool = { - memory_size = 448 - } - } - job_retry = { - enabled = true } - queue = { - delay_webhook_event = 11 - visibility_timeout_seconds = 300 - tags = { - LaneQueue = "lane" - Precedence = "lane" + + orchestration = { + webhook = { + runner = { + maximum_count = 4 + } + + lambda = { + scale_up = { + memory_size = 896 + event_source_mapping = { + batch_size = 50 + } + } + + pool = { + memory_size = 448 + } + } + + queue = { + delay_webhook_event = 11 + visibility_timeout_seconds = 300 + tags = { + LaneQueue = "lane" + Precedence = "lane" + } + } + + job_retry = { + enabled = true + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "resolved"]] + } } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -1777,9 +1980,6 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "resolved"]] - } } } } @@ -1789,10 +1989,13 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { condition = ( local.translated_experimental.multi_runner_config["resolved"].runner.os == "linux" && local.translated_experimental.multi_runner_config["resolved"].runner.architecture == "x64" - && local.translated_experimental.multi_runner_config["resolved"].runner.maximum_count == 4 + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.maximum_count == 4 && local.translated_experimental.multi_runner_config["resolved"].runner.group_name == "lane-group" + && local.translated_experimental.ssm.housekeeper.lambda.artifact.s3.key == "global-ssm-housekeeper.zip" + && local.translated_experimental.multi_runner_config["resolved"].ssm.housekeeper.lambda.artifact.zip == "README.md" + && local.translated_experimental.multi_runner_config["resolved"].ssm.housekeeper.lambda.artifact.s3 == null ) - error_message = "Runner fields must resolve from experimental global defaults before applying lane overrides." + error_message = "Common runner fields and webhook-owned capacity must resolve from their experimental global defaults before applying per-configuration overrides." } assert { @@ -1804,9 +2007,9 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { && toset(keys(local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer.s3 != null && keys(output.binaries_syncer_map) == ["linux_x64"] - && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" ) - error_message = "Global compute-provider defaults must merge into the required lane selector while lane values take precedence." + error_message = "Global compute-provider defaults must merge into the required runner-configuration selector while configuration values take precedence." } assert { @@ -1828,7 +2031,7 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { && output.binaries_syncer_map["linux_x64"].lambda.timeout == 240 && output.binaries_syncer_map["linux_x64"].bucket.tags["BinaryBucket"] == "global" ) - error_message = "A lane must be able to enable the globally configured runner-binary distribution and syncer while the shared settings remain global." + error_message = "A runner configuration must be able to enable the globally configured runner-binary distribution and syncer while the shared settings remain global." } assert { @@ -1849,47 +2052,49 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { assert { condition = ( - module.runner_stacks["resolved"].scale_up.lambda.runtime == "nodejs22.x" - && local.translated_experimental.lambda.scale.artifact.zip == null - && local.translated_experimental.lambda.scale.artifact.s3.key == "nested-runners.zip" - && local.translated_experimental.lambda.scale.artifact.s3.object_version == "nested-runners-version" + module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.runtime == "nodejs22.x" + && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.zip == null + && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.key == "nested-runners.zip" + && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.object_version == "nested-runners-version" && local.translated_experimental.lambda.artifact.s3.bucket == "experimental-lambda-artifacts" - && local.translated_experimental.multi_runner_config["resolved"].lambda.zip == null - && local.translated_experimental.multi_runner_config["resolved"].lambda.s3.bucket == "experimental-lambda-artifacts" - && local.translated_experimental.multi_runner_config["resolved"].lambda.s3.key == "nested-runners.zip" - && local.translated_experimental.multi_runner_config["resolved"].lambda.s3.object_version == "nested-runners-version" + && local.translated_experimental.multi_runner_config["resolved"].lambda.artifact.s3.bucket == "experimental-lambda-artifacts" + && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].lambda), "zip") + && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].lambda), "s3") + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.artifact.zip == null + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.artifact.s3.key == "nested-runners.zip" + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.artifact.s3.object_version == "nested-runners-version" && local.translated_experimental.lambda.principals == tolist([{ type = "Service" identifiers = tolist(["states.amazonaws.com"]) }]) - && module.runner_stacks["resolved"].scale_up.lambda.s3_bucket == "experimental-lambda-artifacts" - && module.runner_stacks["resolved"].scale_up.lambda.s3_key == "nested-runners.zip" - && module.runner_stacks["resolved"].scale_up.lambda.s3_object_version == "nested-runners-version" - && module.runner_stacks["resolved"].scale_up.lambda.memory_size == 896 - && module.runner_stacks["resolved"].scale_up.lambda.timeout == 40 - && module.runner_stacks["resolved"].scale_down.lambda.timeout == 75 - && module.runner_stacks["resolved"].pool.lambda.memory_size == 448 - && local.translated_experimental.multi_runner_config["resolved"].lambda.scale_up.event_source_mapping.batch_size == 50 - && module.runner_stacks["resolved"].runner.role.path == "/experimental/" - && module.runner_stacks["resolved"].scale_up.role.path == "/lane-lambda/" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.s3_bucket == "experimental-lambda-artifacts" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.s3_key == "nested-runners.zip" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.s3_object_version == "nested-runners-version" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.memory_size == 896 + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.timeout == 40 + && module.runner_configs["resolved"].orchestration.webhook.scale_down.lambda.timeout == 75 + && module.runner_configs["resolved"].orchestration.webhook.pool.lambda.memory_size == 448 + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 50 + && module.runner_configs["resolved"].runner.role.path == "/experimental/" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.role.path == "/lane-lambda/" ) - error_message = "V2 values must resolve in lane-over-experimental-global precedence order without stable-input fallback." + error_message = "V2 values must resolve in configuration-over-experimental-global precedence order without stable-input fallback." } assert { condition = ( - local.translated_experimental.multi_runner_config["resolved"].queue.delay_webhook_event == 11 - && local.translated_experimental.multi_runner_config["resolved"].queue.job_queue_retention_in_seconds == 172800 - && local.translated_experimental.multi_runner_config["resolved"].queue.visibility_timeout_seconds == 300 - && local.translated_experimental.multi_runner_config["resolved"].queue.redrive_build_queue.enabled - && local.translated_experimental.multi_runner_config["resolved"].queue.redrive_build_queue.maxReceiveCount == 7 - && local.translated_experimental.multi_runner_config["resolved"].queue.tags == tomap({ + local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.delay_webhook_event == 11 + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.job_queue_retention_in_seconds == 172800 + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.visibility_timeout_seconds == 300 + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.redrive_build_queue.enabled + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount == 7 + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.tags == tomap({ GlobalQueue = "global" LaneQueue = "lane" Precedence = "lane" }) - && local.translated_experimental.queue.encryption == var.experimental.queue.encryption - && local.translated_experimental.ssm.kms_key_id == local.translated_experimental.queue.encryption.kms_master_key_id + && local.translated_experimental.orchestration.webhook.queue.encryption == var.experimental.orchestration.webhook.queue.encryption + && local.translated_experimental.ssm.kms_key_id == local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id && aws_sqs_queue.queued_builds["resolved"].delay_seconds == 11 && aws_sqs_queue.queued_builds["resolved"].message_retention_seconds == 172800 && aws_sqs_queue.queued_builds["resolved"].visibility_timeout_seconds == 300 @@ -1907,26 +2112,28 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { Precedence = "lane" }) ) - error_message = "V2 queue leaves must resolve lane over experimental-global values, merge queue tags, and apply global nested encryption to the build queue and DLQ." + error_message = "V2 queue leaves must resolve configuration over experimental-global values, merge queue tags, and apply global nested encryption to the build queue and DLQ." } assert { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps + && !contains(keys(local.translated_experimental), "enterprise_server") + && !contains(keys(local.translated_experimental), "user_agent") && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server && local.translated_experimental.github.user_agent == var.experimental.github.user_agent && local.translated_experimental.github.enterprise_server.url == "https://experimental-shared.example.com" && !local.translated_experimental.github.enterprise_server.ssl_verify && local.translated_experimental.github.user_agent == "experimental-runner-user-agent" && length(local.translated_experimental.github.additional_apps) == 0 - && local.translated_experimental.multi_runner_config["resolved"].job_retry.enabled - && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" - && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" - && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" - && module.runner_stacks["resolved"].scale_down.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" - && module.runner_stacks["resolved"].pool.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" - && module.runner_stacks["resolved"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == module.ssm.parameters.github_app_id.name + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.job_retry.enabled + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" + && module.runner_configs["resolved"].orchestration.webhook.scale_down.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" + && module.runner_configs["resolved"].orchestration.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" + && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == module.ssm.parameters.github_app_id.name && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables["NESTED_WATCHER"] == "true" && output.instance_termination_watcher.lambda.function.runtime == "nodejs22.x" && output.instance_termination_watcher.lambda.function.architectures == tolist(["arm64"]) @@ -1945,7 +2152,7 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { && output.instance_termination_watcher.lambda_log_group.log_group_class == "STANDARD" && output.instance_termination_handler == null ) - error_message = "V2 runner stacks and the termination watcher must use nested GitHub, Lambda, role, observability, feature, artifact, and sizing settings without flat component fallback." + error_message = "V2 runner configurations and the termination watcher must use nested GitHub, Lambda, role, observability, feature, artifact, sizing, and environment settings without flat component fallback." } assert { @@ -1970,7 +2177,7 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { assert { condition = ( local.translated_experimental.lambda.runtime == "nodejs22.x" - && var.experimental.lambda.webhook.memory_size == 384 + && var.experimental.orchestration.webhook.lambda.webhook.memory_size == 384 && output.webhook.lambda.runtime == "nodejs22.x" && output.webhook.lambda.architectures == tolist(["arm64"]) && output.webhook.lambda.memory_size == 384 @@ -1983,10 +2190,10 @@ run "experimental_v2_applies_global_defaults_and_lane_overrides" { && output.webhook.lambda.environment[0].variables["QUEUE_SELECTION_STRATEGY"] == "all" && output.webhook.eventbridge == null && output.webhook.dispatcher == null - && local.translated_experimental.webhook.matcher_config_parameter_store_tier == "Advanced" - && local.translated_experimental.lambda.webhook.api_gateway_access_log_settings.destination_arn == "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" - && local.translated_experimental.lambda.scale_up.event_source_mapping.batch_size == 25 - && local.translated_experimental.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && local.translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == "Advanced" + && local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" + && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 25 + && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 && !contains(keys(output.webhook.lambda.environment[0].variables), "GHES_URL") ) error_message = "The shared webhook must consume nested GitHub, routing, eventbridge, matcher-tier, artifact, API-access-log, Lambda, and role globals without flat-input leakage." @@ -2065,15 +2272,24 @@ run "experimental_v2_layers_observability_and_ssm" { user_agent = "experimental-observability-user-agent" } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + runner = { + maximum_count = 2 + } + + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } @@ -2084,9 +2300,8 @@ run "experimental_v2_layers_observability_and_ssm" { } runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } ssm = { @@ -2116,6 +2331,9 @@ run "experimental_v2_layers_observability_and_ssm" { Precedence = "global-housekeeper" } lambda = { + artifact = { + zip = "README.md" + } memory_size = 640 timeout = 70 } @@ -2167,6 +2385,15 @@ run "experimental_v2_layers_observability_and_ssm" { InheritedOnly = "inherited" Precedence = "inherited" } + + orchestration = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "inherited"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2175,9 +2402,6 @@ run "experimental_v2_layers_observability_and_ssm" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "inherited"]] - } } overridden = { @@ -2185,6 +2409,15 @@ run "experimental_v2_layers_observability_and_ssm" { OverriddenOnly = "overridden" Precedence = "overridden" } + + orchestration = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "overridden"]] + } + } + } + ssm = { paths = { root = "/lane-ssm" @@ -2219,6 +2452,7 @@ run "experimental_v2_layers_observability_and_ssm" { } } } + observability = { logs = { level = "warn" @@ -2244,6 +2478,7 @@ run "experimental_v2_layers_observability_and_ssm" { } } } + compute_provider = { ec2 = { instance_types = ["c5.large"] @@ -2252,9 +2487,6 @@ run "experimental_v2_layers_observability_and_ssm" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "overridden"]] - } } } } @@ -2268,7 +2500,7 @@ run "experimental_v2_layers_observability_and_ssm" { && local.translated_experimental.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer.s3 == null && !contains(keys(output.binaries_syncer_map), "linux_x64") ) - error_message = "A disabled binary syncer must gain a known null S3 value only in the final canonical lane and create no shared syncer resources." + error_message = "A disabled binary syncer must gain a known null S3 value only in the final canonical runner configuration and create no shared syncer resources." } assert { @@ -2287,7 +2519,7 @@ run "experimental_v2_layers_observability_and_ssm" { && local.translated_experimental.observability.metrics.metric.enable_spot_termination && !local.translated_experimental.observability.metrics.metric.enable_spot_termination_warning ) - error_message = "A lane omitting observability must inherit every runner-stack logging, tracing, and metrics leaf while watcher-only metric switches remain global." + error_message = "A runner configuration omitting observability must inherit every runner-config logging, tracing, and metrics leaf while watcher-only metric switches remain global." } assert { @@ -2304,7 +2536,7 @@ run "experimental_v2_layers_observability_and_ssm" { && !local.translated_experimental.multi_runner_config["overridden"].observability.metrics.metric.enable_github_app_rate_limit && local.translated_experimental.multi_runner_config["overridden"].observability.metrics.metric.enable_job_retry ) - error_message = "Lane observability values must override every lane-owned runner-stack logging, tracing, and metrics leaf." + error_message = "Runner-configuration observability values must override every configuration-owned logging, tracing, and metrics leaf." } assert { @@ -2317,11 +2549,13 @@ run "experimental_v2_layers_observability_and_ssm" { && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.state == "DISABLED" && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.lambda.memory_size == 640 && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.lambda.timeout == 70 + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.lambda.artifact.zip == "README.md" + && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.lambda.artifact.s3 == null && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.config.tokenPath == "/global/cleanup/tokens" && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.config.minimumDaysOld == 6 && local.translated_experimental.multi_runner_config["inherited"].ssm.housekeeper.config.dryRun ) - error_message = "A lane omitting SSM values must inherit global paths, KMS ownership, and housekeeper settings." + error_message = "A runner configuration omitting SSM values must inherit global paths, KMS ownership, and housekeeper settings." } assert { @@ -2334,48 +2568,50 @@ run "experimental_v2_layers_observability_and_ssm" { && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.state == "ENABLED" && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.lambda.memory_size == 768 && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.lambda.timeout == 45 + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.lambda.artifact.zip == "README.md" + && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.lambda.artifact.s3 == null && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.config.tokenPath == "/lane/cleanup/tokens" && local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.config.minimumDaysOld == 2 && !local.translated_experimental.multi_runner_config["overridden"].ssm.housekeeper.config.dryRun ) - error_message = "Lane SSM paths and housekeeper leaves must override globals while the global KMS key ID remains shared by every lane." + error_message = "Runner-configuration SSM paths and housekeeper leaves must override globals while the global KMS key ID remains shared by every runner configuration." } assert { condition = ( - module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-observability.example.com" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-observability-user-agent" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GlobalMetrics" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "true" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" - && module.runner_stacks["inherited"].scale_up.lambda.tracing_config[0].mode == "Active" - && module.runner_stacks["inherited"].scale_up.log_group.retention_in_days == 30 - && module.runner_stacks["inherited"].scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-logs" - && module.runner_stacks["inherited"].scale_up.log_group.log_group_class == "INFREQUENT_ACCESS" - && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" - && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LaneMetrics" - && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "false" - && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" - && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "true" - && module.runner_stacks["overridden"].scale_up.lambda.tracing_config[0].mode == "PassThrough" - && module.runner_stacks["overridden"].scale_up.log_group.retention_in_days == 7 - && module.runner_stacks["overridden"].scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/lane-logs" - && module.runner_stacks["overridden"].scale_up.log_group.log_group_class == "STANDARD" + module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-observability.example.com" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-observability-user-agent" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GlobalMetrics" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "true" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.tracing_config[0].mode == "Active" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.retention_in_days == 30 + && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-logs" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.log_group_class == "INFREQUENT_ACCESS" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LaneMetrics" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "false" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "true" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.tracing_config[0].mode == "PassThrough" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.retention_in_days == 7 + && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/lane-logs" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.log_group_class == "STANDARD" ) - error_message = "Resolved global GitHub connection settings and global/lane observability must reach runner-stack Lambda and log-group resources." + error_message = "Resolved global GitHub connection settings and global/per-configuration observability must reach runner-config Lambda and log-group resources." } assert { condition = ( - module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/global-ssm/inherited/global-tokens" - && module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/global-ssm/inherited/global-config" - && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/lane-ssm/overridden/lane-tokens" - && module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/lane-ssm/overridden/lane-config" + module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/global-ssm/inherited/global-tokens" + && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/global-ssm/inherited/global-config" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/lane-ssm/overridden/lane-tokens" + && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/lane-ssm/overridden/lane-config" && tomap({ - for tag in jsondecode(module.runner_stacks["inherited"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + for tag in jsondecode(module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ ExperimentalOnly = "experimental" @@ -2386,7 +2622,7 @@ run "experimental_v2_layers_observability_and_ssm" { "ghr:environment" = "github-actions" }) && tomap({ - for tag in jsondecode(module.runner_stacks["overridden"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + for tag in jsondecode(module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ ExperimentalOnly = "experimental" @@ -2399,7 +2635,7 @@ run "experimental_v2_layers_observability_and_ssm" { "ghr:environment" = "github-actions" }) ) - error_message = "Resolved lane roots and layered SSM parameter tags must reach runner-stack runtime configuration." + error_message = "Resolved runner-configuration roots and layered SSM parameter tags must reach runner-config runtime configuration." } assert { @@ -2413,14 +2649,14 @@ run "experimental_v2_layers_observability_and_ssm" { LaneHousekeeperOnly = "lane-housekeeper" Precedence = "lane-housekeeper" }) - && module.runner_stacks["inherited"].scale_up.log_group.tags == tomap({ + && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.tags == tomap({ ExperimentalOnly = "experimental" InheritedOnly = "inherited" GlobalLogOnly = "global-log" Precedence = "global-log" "ghr:environment" = "github-actions" }) - && module.runner_stacks["overridden"].scale_up.log_group.tags == tomap({ + && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.tags == tomap({ ExperimentalOnly = "experimental" OverriddenOnly = "overridden" GlobalLogOnly = "global-log" @@ -2429,7 +2665,7 @@ run "experimental_v2_layers_observability_and_ssm" { "ghr:environment" = "github-actions" }) ) - error_message = "Global and lane observability and SSM housekeeper tags must merge with narrower scopes taking precedence." + error_message = "Global and per-configuration observability and SSM housekeeper tags must merge with narrower scopes taking precedence." } assert { @@ -2489,10 +2725,22 @@ run "experimental_v2_requires_global_github_app" { multi_runner_config = { missing_github_app = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "missing-github-app"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2501,9 +2749,6 @@ run "experimental_v2_requires_global_github_app" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "missing-github-app"]] - } } } } @@ -2548,10 +2793,22 @@ run "experimental_v2_rejects_incomplete_global_github_app" { multi_runner_config = { incomplete_github_app = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "incomplete-github-app"]] + } + } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2560,9 +2817,6 @@ run "experimental_v2_rejects_incomplete_global_github_app" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "incomplete-github-app"]] - } } } } @@ -2611,10 +2865,22 @@ run "experimental_v2_rejects_incomplete_additional_github_app" { multi_runner_config = { incomplete_additional_app = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "incomplete-additional-app"]] + } + } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2623,9 +2889,6 @@ run "experimental_v2_rejects_incomplete_additional_github_app" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "incomplete-additional-app"]] - } } } } @@ -2638,10 +2901,6 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { command = plan variables { - github_app = { - id = "flat-app-id" - } - experimental = { github = { app = { @@ -2650,15 +2909,20 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } @@ -2671,10 +2935,32 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { multi_runner_config = { mismatched_primary_app = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-primary-app"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2683,9 +2969,6 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "mismatched-primary-app"]] - } } } } @@ -2693,7 +2976,7 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { assert { condition = ( - var.github_app.id == "flat-app-id" + var.github_app.id == "123456" && local.translated_experimental.github.app.id == "different-app-id" && length(local.github_app_parameters.id) == 1 && output.ssm_parameters.id.name == "/github-action-runners/github-actions/app/github_app_id" @@ -2725,15 +3008,20 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } @@ -2746,10 +3034,32 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { multi_runner_config = { mismatched_additional_apps = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-additional-apps"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2758,9 +3068,6 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "mismatched-additional-apps"]] - } } } } @@ -2798,15 +3105,20 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled url = "https://experimental-disabled-deregistration.example.com" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } @@ -2826,10 +3138,32 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled multi_runner_config = { disabled_deregistration = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "disabled-deregistration"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2838,9 +3172,6 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "disabled-deregistration"]] - } } } } @@ -2852,9 +3183,9 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && !local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration && output.instance_termination_watcher != null - && module.runner_stacks["disabled_deregistration"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-disabled-deregistration.example.com" + && module.runner_configs["disabled_deregistration"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-disabled-deregistration.example.com" ) - error_message = "The termination watcher must remain enabled while the v2 runner stack uses the translated enterprise-server URL when deregistration is disabled." + error_message = "The termination watcher must remain enabled while the v2 runner configuration uses the translated enterprise-server URL when deregistration is disabled." } } @@ -2879,15 +3210,20 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { url = "https://experimental-watcher.example.com" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } @@ -2907,20 +3243,39 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { multi_runner_config = { mismatched_watcher_ghes = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } - compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-watcher-ghes"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "mismatched-watcher-ghes"]] + + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } } } } @@ -2933,7 +3288,7 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" - && module.runner_stacks["mismatched_watcher_ghes"].scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" + && module.runner_configs["mismatched_watcher_ghes"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" && var.ghes_url == "https://flat-watcher.example.com" ) error_message = "An enabled v2 termination watcher must use the translated enterprise-server URL instead of a deliberately different flat GHES URL." @@ -2954,22 +3309,30 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + runner = { + maximum_count = 2 + } + + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } compute_provider = { ec2 = { @@ -2979,6 +3342,24 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { } multi_runner_config = { flat_only = { + orchestration = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "flat-only-kms"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -2987,9 +3368,6 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "flat-only-kms"]] - } } } } @@ -3019,22 +3397,30 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + runner = { + maximum_count = 2 + } + + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } ssm = { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-only" @@ -3047,6 +3433,24 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { } multi_runner_config = { experimental_only = { + orchestration = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "experimental-only-kms"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3055,9 +3459,6 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "experimental-only-kms"]] - } } } } @@ -3087,22 +3488,30 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + runner = { + maximum_count = 2 + } + + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } ssm = { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-mismatch" @@ -3115,6 +3524,24 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { } multi_runner_config = { mismatched = { + orchestration = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "mismatched-kms"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3123,9 +3550,6 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "mismatched-kms"]] - } } } } @@ -3152,15 +3576,20 @@ run "experimental_v2_external_role_ignores_global_iam_management" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } @@ -3186,15 +3615,37 @@ run "experimental_v2_external_role_ignores_global_iam_management" { multi_runner_config = { external = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" iam = { role = { arn = "arn:aws:iam::123456789012:role/external-runner" } } } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "external"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3203,9 +3654,6 @@ run "experimental_v2_external_role_ignores_global_iam_management" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "external"]] - } } } } @@ -3217,7 +3665,7 @@ run "experimental_v2_external_role_ignores_global_iam_management" { && length(local.translated_experimental.multi_runner_config["external"].runner.iam.managed_policy_arns) == 0 && local.translated_experimental.multi_runner_config["external"].runner.iam.additional_trust_policy_json == null ) - error_message = "A lane selecting an external runner role must not inherit global managed policies or trust-policy additions." + error_message = "A runner configuration selecting an external runner role must not inherit global managed policies or trust-policy additions." } } @@ -3229,7 +3677,7 @@ run "experimental_v2_rejects_explicit_iam_management_with_external_role" { } override_module { - target = module.runner_stacks["invalid"] + target = module.runner_configs["invalid"] } variables { @@ -3259,9 +3707,8 @@ run "experimental_v2_rejects_explicit_iam_management_with_external_role" { multi_runner_config = { invalid = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" iam = { role = { arn = "arn:aws:iam::123456789012:role/external-runner" @@ -3275,6 +3722,19 @@ run "experimental_v2_rejects_explicit_iam_management_with_external_role" { }) } } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "invalid"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3283,9 +3743,6 @@ run "experimental_v2_rejects_explicit_iam_management_with_external_role" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "invalid"]] - } } } } @@ -3323,18 +3780,26 @@ run "experimental_v2_layers_shared_and_component_tags" { } lambda = { - scale = { - artifact = { - zip = "README.md" - } - } tags = { ExperimentalLambdaOnly = "experimental-lambda" Precedence = "experimental-lambda" } + } + + orchestration = { webhook = { - artifact = { - zip = "README.md" + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } } } } @@ -3354,9 +3819,8 @@ run "experimental_v2_layers_shared_and_component_tags" { } runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" tags = { RunnerOnly = "runner" Precedence = "runner" @@ -3368,28 +3832,54 @@ run "experimental_v2_layers_shared_and_component_tags" { ConfigLambdaOnly = "config-lambda" Precedence = "config-lambda" } - scale_up = { - tags = { - ScaleUpOnly = "scale-up" - Precedence = "scale-up" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 } - } - scale_down = { - tags = { - ScaleDownOnly = "scale-down" - Precedence = "scale-down" + + lambda = { + scale_up = { + tags = { + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + } + } + + scale_down = { + tags = { + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + } + } + } + + queue = { + redrive_build_queue = { + enabled = true + maxReceiveCount = 3 + } + tags = { + SharedQueueOnly = "shared-queue" + Precedence = "shared-queue" + } + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "tagged"]] } } } - queue = { - redrive_build_queue = { - enabled = true - maxReceiveCount = 3 - } - tags = { - SharedQueueOnly = "shared-queue" - Precedence = "shared-queue" + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } } } @@ -3410,10 +3900,6 @@ run "experimental_v2_layers_shared_and_component_tags" { } } } - - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "tagged"]] - } } } } @@ -3440,7 +3926,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].scale_up.lambda.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration.webhook.scale_up.lambda.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" ExperimentalLambdaOnly = "experimental-lambda" @@ -3453,7 +3939,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].scale_up.log_group.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration.webhook.scale_up.log_group.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" SharedLogOnly = "shared-log" @@ -3465,7 +3951,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].scale_up.role.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration.webhook.scale_up.role.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" ScaleUpOnly = "scale-up" @@ -3476,7 +3962,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].runner.role.tags == tomap({ + condition = module.runner_configs["tagged"].runner.role.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" RunnerOnly = "runner" @@ -3487,7 +3973,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].scale_down.lambda.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration.webhook.scale_down.lambda.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" ExperimentalLambdaOnly = "experimental-lambda" @@ -3500,7 +3986,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_stacks["tagged"].scale_down.log_group.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration.webhook.scale_down.log_group.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" SharedLogOnly = "shared-log" @@ -3512,7 +3998,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = output.runners_map_v2["tagged"].pool == null + condition = output.runners_map_v2["tagged"].orchestration.webhook.pool == null error_message = "Experimental v2 must expose a null pool object when no pool configuration is supplied." } } @@ -3533,9 +4019,13 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window webhook_secret = "test-secret" } } - lambda = { - scale_up = { - timeout = 40 + orchestration = { + webhook = { + lambda = { + scale_up = { + timeout = 40 + } + } } } compute_provider = { @@ -3548,13 +4038,36 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window multi_runner_config = { invalid_visibility = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } - queue = { - visibility_timeout_seconds = 239 + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + queue = { + visibility_timeout_seconds = 239 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3563,9 +4076,6 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -3590,11 +4100,15 @@ run "experimental_v2_rejects_conflicting_queue_encryption" { webhook_secret = "test-secret" } } - queue = { - encryption = { - kms_data_key_reuse_period_seconds = null - kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/conflicting-queue" - sqs_managed_sse_enabled = true + orchestration = { + webhook = { + queue = { + encryption = { + kms_data_key_reuse_period_seconds = null + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/conflicting-queue" + sqs_managed_sse_enabled = true + } + } } } compute_provider = { @@ -3607,18 +4121,27 @@ run "experimental_v2_rejects_conflicting_queue_encryption" { multi_runner_config = { invalid_encryption = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -3627,7 +4150,7 @@ run "experimental_v2_rejects_conflicting_queue_encryption" { expect_failures = [terraform_data.validate_experimental] } -run "experimental_v2_rejects_redrive_without_max_receive_count" { +run "experimental_v2_rejects_queue_kms_alias" { command = plan plan_options { @@ -3643,24 +4166,43 @@ run "experimental_v2_rejects_redrive_without_max_receive_count" { webhook_secret = "test-secret" } } - queue = { - redrive_build_queue = { - enabled = true + orchestration = { + webhook = { + queue = { + encryption = { + kms_data_key_reuse_period_seconds = 300 + kms_master_key_id = "alias/build-queue" + sqs_managed_sse_enabled = null + } + } } } compute_provider = { ec2 = { - vpc_id = "vpc-missing-redrive-max" - subnet_ids = ["subnet-missing-redrive-max"] + vpc_id = "vpc-invalid-queue-kms" + subnet_ids = ["subnet-invalid-queue-kms"] } } + multi_runner_config = { - missing_redrive_max = { + invalid_queue_kms = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3669,9 +4211,6 @@ run "experimental_v2_rejects_redrive_without_max_receive_count" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -3680,7 +4219,7 @@ run "experimental_v2_rejects_redrive_without_max_receive_count" { expect_failures = [terraform_data.validate_experimental] } -run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { +run "experimental_v2_rejects_queue_kms_key_id" { command = plan plan_options { @@ -3696,25 +4235,109 @@ run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { webhook_secret = "test-secret" } } - compute_provider = { - ec2 = { - vpc_id = "vpc-nonpositive-redrive-max" - subnet_ids = ["subnet-nonpositive-redrive-max"] + orchestration = { + webhook = { + queue = { + encryption = { + kms_data_key_reuse_period_seconds = 300 + kms_master_key_id = "12345678-1234-1234-1234-123456789012" + sqs_managed_sse_enabled = null + } + } } } + compute_provider = { + ec2 = { + vpc_id = "vpc-invalid-queue-kms-key-id" + subnet_ids = ["subnet-invalid-queue-kms-key-id"] + } + } + multi_runner_config = { - nonpositive_redrive_max = { + invalid_queue_kms_key_id = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_redrive_without_max_receive_count" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + orchestration = { + webhook = { queue = { redrive_build_queue = { - enabled = true - maxReceiveCount = 0 + enabled = true + } + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-redrive-max" + subnet_ids = ["subnet-missing-redrive-max"] + } + } + multi_runner_config = { + missing_redrive_max = { + runner = { + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } } } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3723,8 +4346,69 @@ run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-nonpositive-redrive-max" + subnet_ids = ["subnet-nonpositive-redrive-max"] + } + } + multi_runner_config = { + nonpositive_redrive_max = { + runner = { + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + queue = { + redrive_build_queue = { + enabled = true + maxReceiveCount = 0 + } + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + } } } } @@ -3756,11 +4440,18 @@ run "experimental_v2_rejects_runner_artifact_zip_and_s3" { bucket = "lambda-artifacts" } } - scale = { - artifact = { - zip = "README.md" - s3 = { - key = "runners.zip" + } + + orchestration = { + webhook = { + lambda = { + scale = { + artifact = { + zip = "README.md" + s3 = { + key = "runners.zip" + } + } } } } @@ -3774,18 +4465,27 @@ run "experimental_v2_rejects_runner_artifact_zip_and_s3" { multi_runner_config = { conflicting_runner_artifact = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -3816,10 +4516,17 @@ run "experimental_v2_rejects_runner_artifact_bucket_without_key" { bucket = "lambda-artifacts" } } - scale = { - artifact = { - s3 = { - key = null + } + + orchestration = { + webhook = { + lambda = { + scale = { + artifact = { + s3 = { + key = null + } + } } } } @@ -3833,18 +4540,27 @@ run "experimental_v2_rejects_runner_artifact_bucket_without_key" { multi_runner_config = { missing_runner_artifact_key = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -3869,11 +4585,15 @@ run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" + orchestration = { + webhook = { + lambda = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } } } } @@ -3887,17 +4607,230 @@ run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { multi_runner_config = { missing_runner_artifact_bucket = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_ssm_housekeeper_artifact_zip_and_s3" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + } + ssm = { + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + s3 = { + key = "ssm-housekeeper.zip" + } + } + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-conflicting-ssm-housekeeper-artifact" + subnet_ids = ["subnet-conflicting-ssm-housekeeper-artifact"] + } + } + multi_runner_config = { + conflicting_ssm_housekeeper_artifact = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } } compute_provider = { ec2 = { instance_types = ["m5.large"] } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_bucket" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + ssm = { + housekeeper = { + lambda = { + artifact = { + s3 = { + key = "ssm-housekeeper.zip" + } + } + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-ssm-housekeeper-artifact-bucket" + subnet_ids = ["subnet-missing-ssm-housekeeper-artifact-bucket"] + } + } + multi_runner_config = { + missing_ssm_housekeeper_artifact_bucket = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } + } + } + } + } + } + + expect_failures = [terraform_data.validate_experimental] +} + +run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_key" { + command = plan + + plan_options { + target = [terraform_data.validate_experimental] + } + + variables { + experimental = { + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + } + ssm = { + housekeeper = { + lambda = { + artifact = { + s3 = { + key = null + } + } + } + } + } + compute_provider = { + ec2 = { + vpc_id = "vpc-missing-ssm-housekeeper-artifact-key" + subnet_ids = ["subnet-missing-ssm-housekeeper-artifact-key"] + } + } + multi_runner_config = { + missing_ssm_housekeeper_artifact_key = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -3949,10 +4882,22 @@ run "experimental_v2_rejects_runner_binaries_artifact_zip_and_s3" { multi_runner_config = { conflicting_binary_artifact = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -3961,9 +4906,6 @@ run "experimental_v2_rejects_runner_binaries_artifact_zip_and_s3" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -4004,10 +4946,22 @@ run "experimental_v2_rejects_runner_binaries_logging_prefix_without_bucket" { multi_runner_config = { binary_logging_prefix = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -4016,9 +4970,6 @@ run "experimental_v2_rejects_runner_binaries_logging_prefix_without_bucket" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -4041,27 +4992,40 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { webhook_secret = "test-secret" } } - lambda = { - scale = { - artifact = { - zip = "README.md" - } - } + orchestration = { webhook = { - artifact = { - zip = "README.md" + lambda = { + scale = { + artifact = { + zip = "README.md" + } + } + + webhook = { + artifact = { + zip = "README.md" + } + } + } + + queue = { + encryption = { + kms_data_key_reuse_period_seconds = 300 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/queue-only" + sqs_managed_sse_enabled = null + } } - } - } - queue = { - encryption = { - kms_data_key_reuse_period_seconds = 300 - kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/queue-only" - sqs_managed_sse_enabled = null } } ssm = { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/shared-control-plane" + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } } compute_provider = { ec2 = { @@ -4073,10 +5037,22 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { multi_runner_config = { mismatched_queue_kms = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -4085,9 +5061,6 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } @@ -4095,8 +5068,9 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { assert { condition = ( - local.translated_experimental.queue.encryption.kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" + local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/shared-control-plane" + && local.translated_experimental.multi_runner_config["mismatched_queue_kms"].orchestration.webhook.queue.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" && aws_sqs_queue.queued_builds["mismatched_queue_kms"].kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" ) error_message = "V2 queue and shared SSM resources must accept and independently use distinct customer-managed KMS keys." @@ -4129,14 +5103,23 @@ run "experimental_v2_rejects_empty_compute_provider" { multi_runner_config = { microvm = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" } - compute_provider = {} - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } } + + compute_provider = {} } } } @@ -4171,23 +5154,33 @@ run "experimental_v2_rejects_invalid_ssm_housekeeper_state" { multi_runner_config = { invalid = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 2 + os = "linux" + architecture = "x64" + } + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } } + ssm = { housekeeper = { state = "PAUSED" } } + compute_provider = { ec2 = { instance_types = ["m5.large"] } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } } } } diff --git a/modules/multi-runner/validations.experimental.tf b/modules/multi-runner/validations.experimental.tf index 3c7c6c86d4..f95d90565f 100644 --- a/modules/multi-runner/validations.experimental.tf +++ b/modules/multi-runner/validations.experimental.tf @@ -65,14 +65,36 @@ resource "terraform_data" "validate_experimental" { error_message = "Each experimental runner configuration must set exactly one compute-provider block. Supported compute-provider blocks: ec2." } + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + length([ + for orchestration_type, orchestration_config in runner_config.orchestration : orchestration_type + if orchestration_config != null + ]) == 1 + ]) + error_message = "Each experimental runner configuration must set exactly one orchestration block. Supported orchestration blocks: webhook." + } + precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : try(coalesce(runner_config.runner.os, var.experimental.runner.os), null) != null && - try(coalesce(runner_config.runner.architecture, var.experimental.runner.architecture), null) != null && - try(coalesce(runner_config.runner.maximum_count, var.experimental.runner.maximum_count), null) != null + try(coalesce(runner_config.runner.architecture, var.experimental.runner.architecture), null) != null ]) - error_message = "Each experimental runner configuration must resolve runner.os, runner.architecture, and runner.maximum_count from the configuration or experimental global runner defaults." + error_message = "Each experimental runner configuration must resolve runner.os and runner.architecture from the configuration or experimental global runner defaults." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.orchestration.webhook == null ? true : + try(coalesce( + runner_config.orchestration.webhook.runner.maximum_count, + var.experimental.orchestration.webhook.runner.maximum_count, + ), null) != null + ]) + error_message = "Each experimental webhook runner configuration must resolve orchestration.webhook.runner.maximum_count from the configuration or experimental global webhook defaults." } precondition { @@ -83,35 +105,48 @@ resource "terraform_data" "validate_experimental" { try(coalesce(runner_config.compute_provider.ec2.subnet_ids, var.experimental.compute_provider.ec2.subnet_ids), null) != null ) ]) - error_message = "Each experimental EC2 runner configuration must resolve compute_provider.ec2.vpc_id and subnet_ids from the lane or experimental global EC2 defaults. Flat v1 inputs are not inherited by v2." + error_message = "Each experimental EC2 runner configuration must resolve compute_provider.ec2.vpc_id and subnet_ids from the configuration or experimental global EC2 defaults. Flat v1 inputs are not inherited by v2." } precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : - coalesce( - runner_config.queue.visibility_timeout_seconds, - var.experimental.queue.visibility_timeout_seconds, - ) >= 6 * coalesce( - runner_config.lambda.scale_up.timeout, - var.experimental.lambda.scale_up.timeout, + runner_config.orchestration.webhook == null ? true : ( + coalesce( + runner_config.orchestration.webhook.queue.visibility_timeout_seconds, + var.experimental.orchestration.webhook.queue.visibility_timeout_seconds, + ) >= 6 * coalesce( + runner_config.orchestration.webhook.lambda.scale_up.timeout, + var.experimental.orchestration.webhook.lambda.scale_up.timeout, + ) ) ]) - error_message = "Each experimental queue.visibility_timeout_seconds must be at least six times the resolved lambda.scale_up.timeout." + error_message = "Each experimental orchestration.webhook.queue.visibility_timeout_seconds must be at least six times the resolved orchestration.webhook.lambda.scale_up.timeout." } precondition { condition = !local.use_multi_runner_config_v2 || ( ( - var.experimental.queue.encryption.sqs_managed_sse_enabled != null && - var.experimental.queue.encryption.kms_master_key_id == null && - var.experimental.queue.encryption.kms_data_key_reuse_period_seconds == null + var.experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled != null && + var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id == null && + var.experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds == null ) || ( - var.experimental.queue.encryption.sqs_managed_sse_enabled == null && - var.experimental.queue.encryption.kms_master_key_id != null + var.experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled == null && + var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id != null ) ) - error_message = "Invalid experimental.queue.encryption configuration. Use SQS-managed encryption, disable it, or configure a KMS key." + error_message = "Invalid experimental.orchestration.webhook.queue.encryption configuration for webhook orchestration. Use SQS-managed encryption, disable it, or configure a KMS key." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id == null || + can(regex( + "^arn:[^:]+:kms:[^:]+:[0-9]{12}:key/.+$", + var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id, + )) + ) + error_message = "experimental.orchestration.webhook.queue.encryption.kms_master_key_id must be a KMS key ARN; key IDs and aliases cannot be used in runner-config IAM policies." } precondition { @@ -173,7 +208,7 @@ resource "terraform_data" "validate_experimental" { runner_config.ssm.paths.root == null || try(startswith(runner_config.ssm.paths.root, "/"), false) ]) ) - error_message = "Experimental ssm.paths.root base paths must start with '/'. The lane key is appended during normalization." + error_message = "Experimental ssm.paths.root base paths must start with '/'. The configuration key is appended during normalization." } precondition { @@ -191,7 +226,10 @@ resource "terraform_data" "validate_experimental" { } precondition { - condition = !local.use_multi_runner_config_v2 || contains( + condition = !local.use_multi_runner_config_v2 || !anytrue([ + for runner_config in values(local.translated_experimental_base.multi_runner_config) : + try(runner_config.compute_provider.ec2.binaries_syncer.enabled, false) + ]) || contains( ["Disabled", "Enabled", "Suspended"], var.experimental.compute_provider.ec2.runner_binaries.s3.versioning, ) @@ -199,7 +237,10 @@ resource "terraform_data" "validate_experimental" { } precondition { - condition = !local.use_multi_runner_config_v2 || contains( + condition = !local.use_multi_runner_config_v2 || !anytrue([ + for runner_config in values(local.translated_experimental_base.multi_runner_config) : + try(runner_config.compute_provider.ec2.binaries_syncer.enabled, false) + ]) || contains( ["DISABLED", "ENABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], var.experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state, ) @@ -209,47 +250,73 @@ resource "terraform_data" "validate_experimental" { precondition { condition = !local.use_multi_runner_config_v2 || contains( ["first", "random", "all"], - var.experimental.webhook.queue_selection_strategy, + var.experimental.orchestration.webhook.queue_selection_strategy, ) - error_message = "experimental.webhook.queue_selection_strategy must be first, random, or all." + error_message = "experimental.orchestration.webhook.queue_selection_strategy must be first, random, or all." } precondition { condition = !local.use_multi_runner_config_v2 || contains( ["Standard", "Advanced"], - var.experimental.webhook.matcher_config_parameter_store_tier, + var.experimental.orchestration.webhook.matcher_config_parameter_store_tier, ) - error_message = "experimental.webhook.matcher_config_parameter_store_tier must be Standard or Advanced." + error_message = "experimental.orchestration.webhook.matcher_config_parameter_store_tier must be Standard or Advanced." } precondition { condition = !local.use_multi_runner_config_v2 || ( !( - var.experimental.lambda.scale.artifact.zip != null && - var.experimental.lambda.scale.artifact.s3 != null + var.experimental.orchestration.webhook.lambda.scale.artifact.zip != null && + var.experimental.orchestration.webhook.lambda.scale.artifact.s3 != null ) && ( - var.experimental.lambda.scale.artifact.s3 == null || ( + var.experimental.orchestration.webhook.lambda.scale.artifact.s3 == null || ( var.experimental.lambda.artifact.s3.bucket != null && - try(var.experimental.lambda.scale.artifact.s3.key != null, false) + try(var.experimental.orchestration.webhook.lambda.scale.artifact.s3.key != null, false) ) ) ) - error_message = "experimental.lambda.scale.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.orchestration.webhook.lambda.scale.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { condition = !local.use_multi_runner_config_v2 || ( !( - var.experimental.lambda.webhook.artifact.zip != null && - var.experimental.lambda.webhook.artifact.s3 != null + var.experimental.orchestration.webhook.lambda.webhook.artifact.zip != null && + var.experimental.orchestration.webhook.lambda.webhook.artifact.s3 != null ) && ( - var.experimental.lambda.webhook.artifact.s3 == null || ( + var.experimental.orchestration.webhook.lambda.webhook.artifact.s3 == null || ( var.experimental.lambda.artifact.s3.bucket != null && - try(var.experimental.lambda.webhook.artifact.s3.key != null, false) + try(var.experimental.orchestration.webhook.lambda.webhook.artifact.s3.key != null, false) ) ) ) - error_message = "experimental.lambda.webhook.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.orchestration.webhook.lambda.webhook.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || ( + !( + var.experimental.ssm.housekeeper.lambda.artifact.zip != null && + var.experimental.ssm.housekeeper.lambda.artifact.s3 != null + ) && alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : !( + runner_config.ssm.housekeeper.lambda.artifact.zip != null && + runner_config.ssm.housekeeper.lambda.artifact.s3 != null + ) + ]) + ) + error_message = "experimental ssm.housekeeper.lambda.artifact must set at most one of zip or s3 at each global or runner-configuration scope." + } + + precondition { + condition = !local.use_multi_runner_config_v2 || alltrue([ + for runner_config in values(local.translated_experimental_base.multi_runner_config) : + runner_config.ssm.housekeeper.lambda.artifact.s3 == null || ( + var.experimental.lambda.artifact.s3.bucket != null && + try(runner_config.ssm.housekeeper.lambda.artifact.s3.key != null, false) + ) + ]) + error_message = "A resolved experimental ssm.housekeeper.lambda.artifact.s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { @@ -326,12 +393,14 @@ resource "terraform_data" "validate_experimental" { precondition { condition = alltrue([ for runner_config in values(local.translated_experimental_base.multi_runner_config) : - !runner_config.queue.redrive_build_queue.enabled || try( - runner_config.queue.redrive_build_queue.maxReceiveCount > 0, - false, + runner_config.orchestration.webhook == null ? true : ( + !runner_config.orchestration.webhook.queue.redrive_build_queue.enabled || try( + runner_config.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount > 0, + false, + ) ) ]) - error_message = "An enabled experimental queue.redrive_build_queue requires maxReceiveCount greater than zero." + error_message = "An enabled experimental orchestration.webhook.queue.redrive_build_queue requires maxReceiveCount greater than zero." } precondition { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index b8b70117cd..175bc4ef54 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -2,27 +2,26 @@ 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. - Set `experimental.multi_runner_config` to opt into provider-oriented runner stacks. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module. + Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module. - Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their lane. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map. - Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every lane should be placed in a global block. When a lane selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role. - Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; IAM consumers keep a static policy shape so its value may be unknown until apply. + Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map. + Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role. + Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling. Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape. - - `tags`: Base tags for v2 build queues, runner stacks, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. + - `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. - `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null. - `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null. - - `runner.os`: Default runner operating system. The default is null; every lane must resolve this field globally or locally. - - `runner.architecture`: Default runner distribution architecture. The default is null; every lane must resolve this field globally or locally. + - `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally. + - `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally. - `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`. - `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`. - - `runner.extra_labels`: Default additional labels combined with each lane's matcher labels. The default is `[]`. + - `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`. - `runner.group_name`: Default GitHub runner group. The default is `Default`. - `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string. - `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`. - `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`. - - `runner.maximum_count`: Default maximum number of runners per lane. The default is null; every lane must resolve this field globally or locally. - `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`. - `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode. - `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`. @@ -31,11 +30,11 @@ variable "experimental" { - `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string. - `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning. - `runner.iam.role.arn`: ARN of the externally managed runner role. - - `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited map. - - `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a lane that explicitly selects its own external role suppresses the inherited value. + - `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map. + - `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value. - `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`. - `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`. - - `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner stacks. The default is null, but a non-empty v2 map requires this object. + - `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object. - `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly. - `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`. - `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter. @@ -62,109 +61,116 @@ variable "experimental" { - `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter. - `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter. - `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering. - - `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-stack GitHub clients and the shared termination watcher. The default is null. - - `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-stack GitHub clients. The default is `true`. - - `github.user_agent`: HTTP User-Agent used by v2 runner-stack GitHub clients. The default is `github-aws-runners`. - - `webhook.queue_selection_strategy`: Queue-selection strategy when multiple lanes match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job. - - `webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. - - `webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events. - - `webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks. - - `lambda.runtime`: Runtime for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`. - - `lambda.architecture`: Architecture for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`. - - `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner stacks, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present. - - `lambda.scale.artifact`: Runner-stack scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used. - - `lambda.scale.artifact.zip`: Optional local path to the runner-stack scale-control-plane Lambda archive. The default is null. - - `lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-stack scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key. - - `lambda.scale.artifact.s3.key`: Object key of the runner-stack scale-control-plane Lambda archive. - - `lambda.scale.artifact.s3.object_version`: Optional object version of the runner-stack scale-control-plane Lambda archive. The default is null. - - `lambda.principals`: Additional principals allowed to assume v2 runner-stack, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks. + - `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null. + - `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`. + - `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`. + - `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present. + - `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`. + - `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`. + - `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks. - `lambda.principals[].type`: IAM principal type. - `lambda.principals[].identifiers`: IAM principal identifiers for the type. - - `lambda.subnet_ids`: Subnets for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`. - - `lambda.security_group_ids`: Security groups for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`. - - `lambda.tags`: Default tags for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. + - `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`. + - `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`. + - `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. - `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`. - `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`. - - `lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`. - - `lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`. - - `lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. - - `lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. - - `lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. - - `lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`. - - `lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`. - - `lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`. - - `lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. - - `lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`. - - `lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. - - `lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`. - - `lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - - `lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. - - `lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period. - - `lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. - - `lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`. - - `lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. - - `lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null. - - `lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key. - - `lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive. - - `lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null. - - `lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block. - - `lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs. - - `lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format. - - `lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`. - - `lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`. - - `lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`. - - `lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`. - - `lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`. - - `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency. - - `lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component. - - `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. - - `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. - - `lambda.pool.config[].size`: Desired runner-pool size for the schedule. - - `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`. - - `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null. - - `lambda.pool.tags`: Default tags for pool resources. The default is `{}`. - - `queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`. - - `queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`. - - `queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `lambda.scale_up.timeout` that inherits it. - - `queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`. - - `queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled. - - `queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`. - - `queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-stack job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode. - - `queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`. - - `queue.encryption.kms_master_key_id`: KMS key identifier used for queue encryption. This key is required syntactically in an explicit `queue.encryption` object but may be null. It is independent from `ssm.kms_key_id`. The queues receive this setting, but current v2 webhook, scale-up, and job-retry role policies do not derive KMS grants from it; grant those roles the required key permissions when selecting a distinct CMK. - - `queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`. - - `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 lanes. The schema default is null, which derives `/github-action-runners/`; normalization appends the lane key only for lane-owned paths. + - `orchestration.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration`, where exactly one typed provider block must be non-null. + - `orchestration.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job. + - `orchestration.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. + - `orchestration.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events. + - `orchestration.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks. + - `orchestration.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally. + - `orchestration.webhook.lambda.scale.artifact`: Runner-config scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used. + - `orchestration.webhook.lambda.scale.artifact.zip`: Optional local path to the runner-config scale-control-plane Lambda archive. The default is null. + - `orchestration.webhook.lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-config scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key. + - `orchestration.webhook.lambda.scale.artifact.s3.key`: Object key of the runner-config scale-control-plane Lambda archive. + - `orchestration.webhook.lambda.scale.artifact.s3.object_version`: Optional object version of the runner-config scale-control-plane Lambda archive. The default is null. + - `orchestration.webhook.lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`. + - `orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`. + - `orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. + - `orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. + - `orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. + - `orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`. + - `orchestration.webhook.lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`. + - `orchestration.webhook.lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`. + - `orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. + - `orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`. + - `orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. + - `orchestration.webhook.lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`. + - `orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. + - `orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period. + - `orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. + - `orchestration.webhook.lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`. + - `orchestration.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `orchestration.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null. + - `orchestration.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key. + - `orchestration.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive. + - `orchestration.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null. + - `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block. + - `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs. + - `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format. + - `orchestration.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`. + - `orchestration.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`. + - `orchestration.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`. + - `orchestration.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`. + - `orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`. + - `orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency. + - `orchestration.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component. + - `orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `orchestration.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule. + - `orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`. + - `orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null. + - `orchestration.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`. + - `orchestration.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`. + - `orchestration.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`. + - `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale_up.timeout` that inherits it. + - `orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`. + - `orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled. + - `orchestration.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`. + - `orchestration.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode. + - `orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`. + - `orchestration.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require. + - `orchestration.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`. + - `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths. - `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`. - `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`. - - `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each lane root. The default is `runners/tokens`. - - `ssm.paths.config`: Persistent runner-configuration path segment below each lane root. The default is `runners/config`. - - `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every v2 runner stack. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created lane runner parameters. - - `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and lane-owned SSM resources. The default is `{}`. - - `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created lane runner parameters. The default is `{}`. - - `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each lane SSM housekeeper. The default is `rate(1 day)`. - - `ssm.housekeeper.state`: Default EventBridge rule state for each lane SSM housekeeper. The default is `ENABLED`. + - `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`. + - `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`. + - `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters. + - `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`. + - `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`. + - `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`. + - `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`. - `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`. + - `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive. + - `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null. + - `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key. + - `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive. + - `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null. - `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`. - `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`. - - `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every lane. The default is null; omit it so each stack derives its isolated token path. + - `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path. - `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`. - `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`. - - `observability.logs.level`: Application log level for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`. - - `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`. - - `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-stack log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. - - `observability.logs.class`: CloudWatch log-group class for v2 runner-stack resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`. - - `observability.logs.tags`: Default tags for v2 runner-stack log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead. - - `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-stack functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks. - - `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`. - - `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-stack functions and translated shared consumers. The default is `false`. - - `observability.metrics.enable`: Enables module-emitted metrics for v2 runner stacks and the shared termination watcher. The default is `false`. - - `observability.metrics.namespace`: CloudWatch namespace for v2 runner-stack and termination-watcher metrics. The default is `GitHub Runners`. + - `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`. + - `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`. + - `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. + - `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`. + - `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead. + - `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks. + - `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`. + - `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`. + - `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`. + - `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`. - `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`. - `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`. - `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`. - `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`. - - `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally. - - `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 lanes. The default is null; every EC2 lane must resolve this field globally or locally. + - `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally. + - `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally. - `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`. - `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`. - `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule. @@ -176,12 +182,12 @@ variable "experimental" { - `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. - `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range. - `compute_provider.ec2.egress_rules[].description`: Optional egress rule description. - - `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 lane unless overridden. The default is `[]`. - - `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 lanes. The default is null; enablement remains lane-owned. + - `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`. + - `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned. - `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null. - - `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 lanes. The default is null. + - `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null. - `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`. - - `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and lane EC2 tags take precedence. + - `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence. - `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda. - `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance. - `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module. @@ -213,7 +219,7 @@ variable "experimental" { - `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module. - `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module. - `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair. - - `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 lanes use the synchronized runner distribution. The default is `true`; a lane may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances. + - `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances. - `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket. - `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket. - `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false. @@ -240,17 +246,16 @@ variable "experimental" { Each `experimental.multi_runner_config` entry supports the following nested fields: - - `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner stacks. + - `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations. - `multi_runner_config[].runner.os`: Runner operating system. - `multi_runner_config[].runner.architecture`: Runner distribution architecture. - `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale. - `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered. - - `multi_runner_config[].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. + - `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true. - `multi_runner_config[].runner.group_name`: GitHub runner group used during registration. - `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names. - `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider. - `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false. - - `multi_runner_config[].runner.maximum_count`: Maximum number of runners for this configuration. - `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode. - `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode. - `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. @@ -258,78 +263,98 @@ variable "experimental" { - `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook. - `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook. - `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed. - - `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-stack` does not create or modify that role. - - `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the lane selects an external `runner.iam.role`. - - `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the lane selects an external `runner.iam.role`. + - `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role. + - `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`. + - `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`. - `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role. - `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. - - `multi_runner_config[].github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used. - - `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-stack Lambda functions. - - `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-stack Lambda functions. - - `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-stack Lambda functions. - - `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-stack Lambda functions. + - `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration.webhook.lambda`. + - `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions. + - `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions. + - `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions. + - `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions. - `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map. - `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`. - `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`. - - `multi_runner_config[].queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default. - - `multi_runner_config[].queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default. - - `multi_runner_config[].queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations. - - `multi_runner_config[].lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. - - `multi_runner_config[].lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. - - `multi_runner_config[].queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value. - - `multi_runner_config[].queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled. - - `multi_runner_config[].queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map. - - `multi_runner_config[].lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB. - - `multi_runner_config[].lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. - - `multi_runner_config[].lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. - - `multi_runner_config[].lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode. - - `multi_runner_config[].lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. - - `multi_runner_config[].lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB. - - `multi_runner_config[].lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. - - `multi_runner_config[].lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down. - - `multi_runner_config[].lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected. - - `multi_runner_config[].lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. - - `multi_runner_config[].lambda.scale_down.idle_config`: Time-based desired idle-runner configurations. - - `multi_runner_config[].lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - - `multi_runner_config[].lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`. - - `multi_runner_config[].lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period. - - `multi_runner_config[].lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. - - `multi_runner_config[].lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. - - `multi_runner_config[].lambda.pool.timeout`: Pool Lambda timeout in seconds. - - `multi_runner_config[].lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. - - `multi_runner_config[].lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component. - - `multi_runner_config[].lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. - - `multi_runner_config[].lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. - - `multi_runner_config[].lambda.pool.config[].size`: Desired number of runners for the schedule. - - `multi_runner_config[].lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners. - - `multi_runner_config[].lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. - - `multi_runner_config[].lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. - - `multi_runner_config[].job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. - - `multi_runner_config[].job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. - - `multi_runner_config[].job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. - - `multi_runner_config[].job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. - - `multi_runner_config[].job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. - - `multi_runner_config[].job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. - - `multi_runner_config[].job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency. - - `multi_runner_config[].job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. - - `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this lane. The configuration key is always appended to preserve lane isolation. The omitted global root derives `/github-action-runners/`. - - `multi_runner_config[].ssm.paths.tokens`: Path segment below the lane root used for runner registration tokens and just-in-time configuration. - - `multi_runner_config[].ssm.paths.config`: Path segment below the lane root used for persistent runner configuration. - - `multi_runner_config[].ssm.tags`: Shared tags for lane-owned SSM resources. These override entry-level `tags`. + - `multi_runner_config[].orchestration`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract. + - `multi_runner_config[].orchestration.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources. + - `multi_runner_config[].orchestration.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration.webhook.runner.maximum_count`. + - `multi_runner_config[].orchestration.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used. + - `multi_runner_config[].orchestration.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration. + - `multi_runner_config[].orchestration.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels. + - `multi_runner_config[].orchestration.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels. + - `multi_runner_config[].orchestration.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job. + - `multi_runner_config[].orchestration.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels. + - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. + - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`. + - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`. + - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`. + - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`. + - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. + - `multi_runner_config[].orchestration.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default. + - `multi_runner_config[].orchestration.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default. + - `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations. + - `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value. + - `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled. + - `multi_runner_config[].orchestration.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map. + - `multi_runner_config[].orchestration.webhook.lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB. + - `multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. + - `multi_runner_config[].orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode. + - `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. + - `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. + - `multi_runner_config[].orchestration.webhook.lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config`: Time-based desired idle-runner configurations. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. + - `multi_runner_config[].orchestration.webhook.lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. + - `multi_runner_config[].orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. + - `multi_runner_config[].orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].orchestration.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component. + - `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `multi_runner_config[].orchestration.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule. + - `multi_runner_config[].orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. + - `multi_runner_config[].orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners. + - `multi_runner_config[].orchestration.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. + - `multi_runner_config[].orchestration.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. + - `multi_runner_config[].orchestration.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. + - `multi_runner_config[].orchestration.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. + - `multi_runner_config[].orchestration.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. + - `multi_runner_config[].orchestration.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].orchestration.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. + - `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`. + - `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration. + - `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration. + - `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`. - `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`. - - `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the lane SSM housekeeper. - - `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the lane SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`. + - `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper. + - `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`. - `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values. + - `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive. + - `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. + - `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key. + - `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive. + - `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive. - `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB. - `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds. - - `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every lane, so omit it to derive each lane's isolated token path. + - `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path. - `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. - `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. - - `multi_runner_config[].observability.logs.level`: Application log level for lane control-plane functions. - - `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for lane resources. - - `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt lane CloudWatch log groups. - - `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for lane resources. - - `multi_runner_config[].observability.logs.tags`: Shared tags for lane CloudWatch log groups. Component tags override this map. + - `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions. + - `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources. + - `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups. + - `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources. + - `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map. - `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks. - `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper. - `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper. @@ -339,6 +364,10 @@ variable "experimental" { - `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics. - `multi_runner_config[].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. - `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration. + - `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled. + - `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. + - `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. + - `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. - `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter. - `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. - `multi_runner_config[].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. @@ -378,23 +407,23 @@ variable "experimental" { - `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances. - `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true. - `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the lane egress rule. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the lane egress rule. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the lane egress rule. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the lane egress rule port range. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Lane egress rule protocol; `-1` allows every protocol. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the lane egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule. - `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the lane egress rule port range. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional lane egress rule description. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range. + - `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description. - `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile. - `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances. - `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances. - `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`. - `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure. - `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures. - - `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A lane null inherits the experimental global value. - - `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A lane null inherits the experimental global value. + - `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value. + - `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value. - `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance. - `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core. - `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. @@ -412,26 +441,11 @@ variable "experimental" { - `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners. - `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent. - `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing. - - `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner stack path when true. + - `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true. - `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent. - `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template. - `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file. - `multi_runner_config[].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. - - `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled. - - `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. - - `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. - - `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. - - `multi_runner_config[].matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration. - - `multi_runner_config[].matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels. - - `multi_runner_config[].matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels. - - `multi_runner_config[].matcherConfig.priority`: Ordering used when multiple configurations match the same job. - - `multi_runner_config[].matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels. - - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. - - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this lane. The default is `[]`. - - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`. - - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`. - - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`. - - `multi_runner_config[].matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. EOT type = object({ @@ -452,7 +466,6 @@ variable "experimental" { name_prefix = optional(string, "") run_as_root = optional(bool, false) run_as = optional(string, "ec2-user") - maximum_count = optional(number, null) ephemeral = optional(bool, false) jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, false) @@ -506,30 +519,12 @@ variable "experimental" { user_agent = optional(string, "github-aws-runners") }), {}) - webhook = optional(object({ - queue_selection_strategy = optional(string, "first") - eventbridge = optional(object({ - enable = optional(bool, true) - accept_events = optional(list(string), []) - }), {}) - matcher_config_parameter_store_tier = optional(string, "Standard") - }), {}) - lambda = optional(object({ artifact = optional(object({ s3 = optional(object({ bucket = optional(string, null) }), {}) }), {}) - scale = optional(object({ - artifact = optional(object({ - zip = optional(string, null) - s3 = optional(object({ - key = string - object_version = optional(string, null) - }), null) - }), {}) - }), {}) runtime = optional(string, "nodejs24.x") architecture = optional(string, "arm64") principals = optional(list(object({ @@ -543,82 +538,108 @@ variable "experimental" { path = optional(string, null) permissions_boundary = optional(string, null) }), {}) - scale_up = optional(object({ - memory_size = optional(number, 512) - timeout = optional(number, 30) - reserved_concurrent_executions = optional(number, 1) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, 10) - maximum_batching_window_in_seconds = optional(number, 0) - }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(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") - })), []) - tags = optional(map(string), {}) - }), {}) + }), {}) + + orchestration = optional(object({ webhook = optional(object({ - artifact = optional(object({ - zip = optional(string, null) - s3 = optional(object({ - key = string - object_version = optional(string, null) - }), null) + queue_selection_strategy = optional(string, "first") + eventbridge = optional(object({ + enable = optional(bool, true) + accept_events = optional(list(string), []) + }), {}) + matcher_config_parameter_store_tier = optional(string, "Standard") + runner = optional(object({ + maximum_count = optional(number, null) }), {}) - api_gateway_access_log_settings = optional(object({ - destination_arn = string - format = string - }), null) - memory_size = optional(number, 256) - timeout = optional(number, 10) - tags = optional(map(string), {}) - }), {}) - pool = optional(object({ - memory_size = optional(number, 512) - timeout = optional(number, 60) - reserved_concurrent_executions = optional(number, 1) - 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), {}) - }), {}) - }), {}) - queue = optional(object({ - delay_webhook_event = optional(number, 30) - job_queue_retention_in_seconds = optional(number, 86400) - visibility_timeout_seconds = optional(number, 180) - redrive_build_queue = optional(object({ - enabled = optional(bool, false) - maxReceiveCount = optional(number, null) - }), { - enabled = false - maxReceiveCount = null - }) - tags = optional(map(string), {}) - encryption = optional(object({ - kms_data_key_reuse_period_seconds = number - kms_master_key_id = string - sqs_managed_sse_enabled = bool - }), { - kms_data_key_reuse_period_seconds = null - kms_master_key_id = null - sqs_managed_sse_enabled = true - }) + lambda = optional(object({ + scale = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + }), {}) + scale_up = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 30) + reserved_concurrent_executions = optional(number, 1) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, 10) + maximum_batching_window_in_seconds = optional(number, 0) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(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") + })), []) + tags = optional(map(string), {}) + }), {}) + webhook = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + api_gateway_access_log_settings = optional(object({ + destination_arn = string + format = string + }), null) + memory_size = optional(number, 256) + timeout = optional(number, 10) + tags = optional(map(string), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 60) + reserved_concurrent_executions = optional(number, 1) + 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), {}) + }), {}) + }), {}) + + queue = optional(object({ + delay_webhook_event = optional(number, 30) + job_queue_retention_in_seconds = optional(number, 86400) + visibility_timeout_seconds = optional(number, 180) + redrive_build_queue = optional(object({ + enabled = optional(bool, false) + maxReceiveCount = optional(number, null) + }), { + enabled = false + maxReceiveCount = null + }) + tags = optional(map(string), {}) + encryption = optional(object({ + kms_data_key_reuse_period_seconds = number + kms_master_key_id = string + sqs_managed_sse_enabled = bool + }), { + kms_data_key_reuse_period_seconds = null + kms_master_key_id = null + sqs_managed_sse_enabled = true + }) + }), {}) + }), {}) }), {}) ssm = optional(object({ @@ -639,6 +660,13 @@ variable "experimental" { state = optional(string, "ENABLED") tags = optional(map(string), {}) lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) memory_size = optional(number, 512) timeout = optional(number, 60) }), {}) @@ -809,7 +837,6 @@ variable "experimental" { name_prefix = optional(string, null) run_as_root = optional(bool, null) run_as = optional(string, null) - maximum_count = optional(number, null) ephemeral = optional(bool, null) jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, null) @@ -829,10 +856,6 @@ variable "experimental" { }), {}) }), {}) - github = optional(object({ - organization_runners = optional(bool, false) - }), {}) - lambda = optional(object({ runtime = optional(string, null) architecture = optional(string, null) @@ -843,68 +866,101 @@ variable "experimental" { path = optional(string, null) permissions_boundary = optional(string, null) }), {}) - scale_up = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) - }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - schedule_expression = optional(string, null) - minimum_running_time_in_minutes = optional(number, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), null) - tags = optional(map(string), {}) - }), {}) - pool = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), null) - include_busy_runners = optional(bool, null) - runner_owner = optional(string, null) - tags = optional(map(string), {}) - }), {}) }), {}) - queue = optional(object({ - delay_webhook_event = optional(number, null) - job_queue_retention_in_seconds = optional(number, null) - visibility_timeout_seconds = optional(number, null) - redrive_build_queue = optional(object({ - enabled = optional(bool, null) - maxReceiveCount = optional(number, null) + orchestration = object({ + webhook = optional(object({ + runner = optional(object({ + maximum_count = optional(number, null) + }), {}) + + github = optional(object({ + organization_runners = optional(bool, false) + }), {}) + + matcherConfig = object({ + labelMatchers = list(list(string)) + exactMatch = optional(bool, false) + bidirectionalLabelMatch = optional(bool, false) + priority = optional(number, 999) + enableDynamicLabels = optional(bool, false) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) + }) + + queue = optional(object({ + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) + redrive_build_queue = optional(object({ + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) + tags = optional(map(string), {}) + }), {}) + + lambda = optional(object({ + scale_up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + 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) + }), {}) + }), {}) + }), 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({ paths = optional(object({ @@ -921,6 +977,13 @@ variable "experimental" { state = optional(string, null) tags = optional(map(string), {}) lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) memory_size = optional(number, null) timeout = optional(number, null) }), {}) @@ -1077,21 +1140,6 @@ variable "experimental" { }), 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(object({ - blocked_keys = optional(list(string), []) - restricted_keys = optional(map(object({ - allowed = optional(list(string), []) - denied = optional(list(string), []) - max = optional(string, null) - })), {}) - }), null) - }) })), {}) }) default = {} diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index 5b5b9e15fc..dba61ffdff 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -1,10 +1,15 @@ locals { + webhook_runner_config = { + for k, v in local.translated_experimental.multi_runner_config : k => v + if v.orchestration.webhook != null + } + runner_matcher_config = { - for k, v in local.translated_experimental.multi_runner_config : k => { + for k, v in local.webhook_runner_config : k => { id = aws_sqs_queue.queued_builds[k].id arn = aws_sqs_queue.queued_builds[k].arn computeProvider = local.compute_provider_types[k] - matcherConfig = v.matcherConfig + matcherConfig = v.orchestration.webhook.matcherConfig } } } @@ -14,9 +19,9 @@ module "webhook" { prefix = var.prefix tags = merge(local.translated_experimental.tags, { "ghr:environment" = var.prefix }) kms_key_arn = local.translated_experimental.ssm.kms_key_id - eventbridge = local.translated_experimental.webhook.eventbridge + eventbridge = local.translated_experimental.orchestration.webhook.eventbridge runner_matcher_config = local.runner_matcher_config - matcher_config_parameter_store_tier = local.translated_experimental.webhook.matcher_config_parameter_store_tier + matcher_config_parameter_store_tier = local.translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier ssm_paths = { root = trimsuffix(coalesce(local.translated_experimental.ssm.paths.root, "/github-action-runners/${var.prefix}"), "/") @@ -27,16 +32,16 @@ module "webhook" { webhook_secret = local.github_app_parameters.webhook_secret } - lambda_s3_bucket = local.translated_experimental.lambda.webhook.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket - webhook_lambda_s3_key = try(local.translated_experimental.lambda.webhook.artifact.s3.key, null) - webhook_lambda_s3_object_version = try(local.translated_experimental.lambda.webhook.artifact.s3.object_version, null) - webhook_lambda_apigateway_access_log_settings = local.translated_experimental.lambda.webhook.api_gateway_access_log_settings + lambda_s3_bucket = local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + webhook_lambda_s3_key = try(local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.key, null) + webhook_lambda_s3_object_version = try(local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.object_version, null) + webhook_lambda_apigateway_access_log_settings = local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings lambda_runtime = local.translated_experimental.lambda.runtime lambda_architecture = local.translated_experimental.lambda.architecture - lambda_zip = local.translated_experimental.lambda.webhook.artifact.zip - lambda_timeout = local.translated_experimental.lambda.webhook.timeout - lambda_memory_size = local.translated_experimental.lambda.webhook.memory_size - lambda_tags = merge(local.translated_experimental.lambda.tags, local.translated_experimental.lambda.webhook.tags) + lambda_zip = local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip + lambda_timeout = local.translated_experimental.orchestration.webhook.lambda.webhook.timeout + lambda_memory_size = local.translated_experimental.orchestration.webhook.lambda.webhook.memory_size + lambda_tags = merge(local.translated_experimental.lambda.tags, local.translated_experimental.orchestration.webhook.lambda.webhook.tags) tracing_config = local.translated_experimental.observability.tracing logging_retention_in_days = local.translated_experimental.observability.logs.retention_in_days logging_kms_key_id = local.translated_experimental.observability.logs.kms_key_id @@ -45,7 +50,7 @@ module "webhook" { role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) repository_white_list = local.translated_experimental.github.repository_white_list - queue_selection_strategy = local.translated_experimental.webhook.queue_selection_strategy + queue_selection_strategy = local.translated_experimental.orchestration.webhook.queue_selection_strategy lambda_subnet_ids = local.translated_experimental.lambda.subnet_ids lambda_security_group_ids = local.translated_experimental.lambda.security_group_ids diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md new file mode 100644 index 0000000000..073efafd43 --- /dev/null +++ b/modules/orchestration-providers/webhook/README.md @@ -0,0 +1,56 @@ +# Webhook orchestration provider + +This internal module owns the event-driven runner demand controls used by `runner-config`: scale-up, scale-down, scheduled pool reconciliation, and optional queued-job retry. It receives the common GitHub, Lambda, runner-registration, SSM, observability, and selected compute-provider contracts from the parent configuration module, then resolves webhook-specific defaults and tag precedence before invoking its leaf modules. Its `config.runner.maximum_count` capacity limit is forwarded to both scale-up and pool, without a common-runner fallback. It also combines the shared Lambda artifact bucket with its own `config.lambda.scale.artifact` zip or S3 key/version; provider-specific artifact fields do not leak into the common Lambda contract. + +`runner-config` selects this provider when `orchestration.webhook` is the one populated orchestration block. The parent continues to own common runner resources, shared SSM configuration, and compute-provider selection. A future orchestration provider should be implemented as a sibling module with the same parent-facing resource boundary; it should not add its stateful resources to this webhook module. + +The scale-down lifecycle is documented in the [scale-down state diagram](./scale-down-state-diagram.md). + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [job\_retry](#module\_job\_retry) | ./job-retry | n/a | +| [pool](#module\_pool) | ./pool | n/a | +| [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | +| [config](#input\_config) | Resolved provider-owned values from orchestration.webhook, including the runner capacity limit used by scaling and pool controls. |
object({
runner = object({
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
scale = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(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 = optional(map(string), {})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | +| [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | +| [lambda](#input\_lambda) | Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection. |
object({
artifact = object({
s3 = object({
bucket = optional(string, null)
})
})
runtime = string
architecture = string
subnet_ids = list(string)
security_group_ids = list(string)
tags = optional(map(string), {})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
| n/a | yes | +| [observability](#input\_observability) | Common logging, tracing, and metrics configuration consumed by webhook controls. |
object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
tags = optional(map(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
})
})
})
| n/a | yes | +| [prefix](#input\_prefix) | Prefix used to identify resources created for this webhook orchestration provider. | `string` | n/a | yes | +| [runner](#input\_runner) | Common runner registration values consumed by webhook demand controls. Capacity remains provider-owned under config.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
})
| n/a | yes | +| [runner\_provider](#input\_runner\_provider) | Selected compute-provider capabilities consumed by webhook scaling, pool, and retry controls. |
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
})
pool = object({
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
})
| n/a | yes | +| [ssm](#input\_ssm) | Resolved Parameter Store paths, optional decrypt key, and runtime parameter tags. |
object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
kms_key_id = optional(string, null)
parameter_store_tags = string
})
| n/a | yes | +| [tags](#input\_tags) | Base tags available to webhook-provider resources. Component-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [job\_retry](#output\_job\_retry) | Job-retry resources. Null when job retry is disabled. | +| [pool](#output\_pool) | Scheduled pool resources. Null when no pool schedule is configured. | +| [scale\_down](#output\_scale\_down) | Scale-down control-plane resources. | +| [scale\_up](#output\_scale\_up) | Scale-up control-plane resources. | + diff --git a/modules/orchestration-providers/webhook/job-retry.tf b/modules/orchestration-providers/webhook/job-retry.tf new file mode 100644 index 0000000000..651e12c9ea --- /dev/null +++ b/modules/orchestration-providers/webhook/job-retry.tf @@ -0,0 +1,48 @@ +module "job_retry" { + source = "./job-retry" + count = local.job_retry_enabled ? 1 : 0 + + config = { + prefix = local.resolved_config.prefix + aws_partition = var.aws_partition + lambda = { + artifact = local.resolved_config.lambda.artifact + runtime = local.resolved_config.lambda.runtime + architecture = local.resolved_config.lambda.architecture + memory_size = local.resolved_config.job_retry.lambda.memory_size + timeout = local.resolved_config.job_retry.lambda.timeout + reserved_concurrent_executions = local.resolved_config.job_retry.lambda.reserved_concurrent_executions + environment_variables = {} + vpc = { + subnet_ids = local.resolved_config.lambda.subnet_ids + security_group_ids = local.resolved_config.lambda.security_group_ids + } + role = local.resolved_config.lambda.role + } + runner = { + name_prefix = local.resolved_config.runner.name_prefix + } + github = local.resolved_config.github + queue = { + build = local.resolved_config.queue.build + kms_key_id = local.resolved_config.queue.kms_key_id + event_source_mapping = local.resolved_config.queue.event_source_mapping + encryption = { + sqs_managed_sse_enabled = true + kms_master_key_id = null + kms_data_key_reuse_period_seconds = null + } + } + ssm = { + kms_key_id = local.resolved_config.ssm.kms_key_id + } + observability = local.resolved_config.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/orchestration-providers/webhook/job-retry/README.md similarity index 60% rename from modules/runner-stack/job-retry/README.md rename to modules/orchestration-providers/webhook/job-retry/README.md index 0bda4e9442..dece345834 100644 --- a/modules/runner-stack/job-retry/README.md +++ b/modules/orchestration-providers/webhook/job-retry/README.md @@ -4,21 +4,21 @@ This module is listening to a SQS queue where the scale-up lambda publishes mess ## 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. +The module is an inner module used by the webhook orchestration provider 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 @@ -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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `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)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(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_id = 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
})
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 | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.

- `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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `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)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
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_id = 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
})
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/job-retry/iam-policies.tf b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf similarity index 65% rename from modules/runner-stack/job-retry/iam-policies.tf rename to modules/orchestration-providers/webhook/job-retry/iam-policies.tf index e1778f9035..0e79e8a265 100644 --- a/modules/runner-stack/job-retry/iam-policies.tf +++ b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf @@ -1,6 +1,7 @@ # IAM policies attached to the job-retry Lambda role. data "aws_iam_policy_document" "lambda_assume_role" { statement { + sid = "WebhookJobRetryAssumeRole" actions = ["sts:AssumeRole"] principals { @@ -21,6 +22,7 @@ data "aws_iam_policy_document" "lambda_assume_role" { data "aws_iam_policy_document" "job_retry_logging" { statement { + sid = "WebhookJobRetryWriteLogs" effect = "Allow" actions = [ @@ -35,6 +37,7 @@ data "aws_iam_policy_document" "job_retry_logging" { data "aws_iam_policy_document" "lambda_xray" { count = var.config.observability.tracing.mode != null ? 1 : 0 + # AWS X-Ray write/read trace APIs do not support resource-level permissions. statement { sid = "AllowXRay" effect = "Allow" @@ -50,6 +53,7 @@ data "aws_iam_policy_document" "lambda_xray" { data "aws_iam_policy_document" "job_retry" { statement { + sid = "WebhookJobRetryReadGitHubAppParameters" effect = "Allow" actions = [ @@ -65,6 +69,7 @@ data "aws_iam_policy_document" "job_retry" { } statement { + sid = "WebhookJobRetryConsumeRetryQueue" effect = "Allow" actions = [ @@ -77,6 +82,7 @@ data "aws_iam_policy_document" "job_retry" { } statement { + sid = "WebhookJobRetryPublishBuildQueue" effect = "Allow" actions = [ @@ -87,18 +93,30 @@ data "aws_iam_policy_document" "job_retry" { resources = [var.config.queue.build.arn] } - statement { - effect = "Allow" + dynamic "statement" { + for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + iterator = kms_key - actions = [ - "kms:Encrypt", - "kms:Decrypt", - "kms:GenerateDataKey", - ] + content { + sid = "WebhookJobRetryDecryptParameterStore" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [kms_key.value] + } + } - resources = [coalesce( - var.config.ssm.kms_key_id, - "arn:${var.config.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", - )] + dynamic "statement" { + for_each = var.config.queue.kms_key_id == null ? [] : [var.config.queue.kms_key_id] + iterator = kms_key + + content { + sid = "WebhookJobRetryEncryptBuildQueueMessage" + effect = "Allow" + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + resources = [kms_key.value] + } } } diff --git a/modules/runner-stack/job-retry/job-retry.tf b/modules/orchestration-providers/webhook/job-retry/job-retry.tf similarity index 99% rename from modules/runner-stack/job-retry/job-retry.tf rename to modules/orchestration-providers/webhook/job-retry/job-retry.tf index a21ab09342..7d819786aa 100644 --- a/modules/runner-stack/job-retry/job-retry.tf +++ b/modules/orchestration-providers/webhook/job-retry/job-retry.tf @@ -167,7 +167,7 @@ data "aws_iam_policy_document" "deny_insecure_transport" { ] resources = [ - "*" + aws_sqs_queue.job_retry_check_queue.arn ] condition { diff --git a/modules/runner-stack/job-retry/outputs.tf b/modules/orchestration-providers/webhook/job-retry/outputs.tf similarity index 100% rename from modules/runner-stack/job-retry/outputs.tf rename to modules/orchestration-providers/webhook/job-retry/outputs.tf diff --git a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl b/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl similarity index 82% rename from modules/runner-stack/job-retry/tests/job-retry.tftest.hcl rename to modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl index a332541418..f99570354e 100644 --- a/modules/runner-stack/job-retry/tests/job-retry.tftest.hcl +++ b/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl @@ -10,6 +10,7 @@ mock_provider "aws" { arn = "arn:aws:iam::123456789012:role/job-retry-test" } } + } variables { @@ -90,6 +91,7 @@ variables { url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/build-queue-test" event_source_mapping = { batch_size = 10 maximum_batching_window_in_seconds = 0 @@ -183,15 +185,45 @@ run "preserves_nested_job_retry_configuration" { assert { condition = ( output.lambda.log_group.log_group_class == "INFREQUENT_ACCESS" - && length(data.aws_iam_policy_document.job_retry.statement) == 4 - && data.aws_iam_policy_document.job_retry.statement[3].resources == toset(["arn:aws:kms:eu-west-1:123456789012:key/job-retry-test"]) + && length(data.aws_iam_policy_document.job_retry.statement) == 5 + && one([ + for statement in data.aws_iam_policy_document.job_retry.statement : statement + if statement.sid == "WebhookJobRetryDecryptParameterStore" + ]).resources == toset(["arn:aws:kms:eu-west-1:123456789012:key/job-retry-test"]) + && one([ + for statement in data.aws_iam_policy_document.job_retry.statement : statement + if statement.sid == "WebhookJobRetryDecryptParameterStore" + ]).actions == toset(["kms:Decrypt"]) + && one([ + for statement in data.aws_iam_policy_document.job_retry.statement : statement + if statement.sid == "WebhookJobRetryEncryptBuildQueueMessage" + ]).resources == toset(["arn:aws:kms:eu-west-1:123456789012:key/build-queue-test"]) + && one([ + for statement in data.aws_iam_policy_document.job_retry.statement : statement + if statement.sid == "WebhookJobRetryEncryptBuildQueueMessage" + ]).actions == toset(["kms:Decrypt", "kms:GenerateDataKey"]) && 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 = "Logging, KMS, complete VPC, tracing, and extra role-principal configuration must be preserved." + error_message = "Logging, distinct Parameter Store/build-queue KMS grants, complete VPC, tracing, and extra role-principal configuration must be preserved." } + + assert { + condition = ( + data.aws_iam_policy_document.lambda_xray[0].statement[0].sid == "AllowXRay" + && data.aws_iam_policy_document.lambda_xray[0].statement[0].resources == toset(["*"]) + && toset(data.aws_iam_policy_document.lambda_xray[0].statement[0].actions) == toset([ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments", + ]) + ) + error_message = "Only the resource-agnostic X-Ray APIs may retain a wildcard resource in the job-retry policies." + } + } run "does_not_enable_partial_vpc_configuration" { @@ -289,8 +321,12 @@ run "does_not_enable_partial_vpc_configuration" { condition = ( length(aws_lambda_function.job_retry.vpc_config) == 0 && length(aws_iam_role_policy_attachment.job_retry_vpc_execution_role) == 0 - && data.aws_iam_policy_document.job_retry.statement[3].resources == toset(["arn:aws:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000"]) + && length(data.aws_iam_policy_document.job_retry.statement) == 3 + && length([ + for statement in data.aws_iam_policy_document.job_retry.statement : statement + if contains(statement.actions, "kms:Decrypt") + ]) == 0 ) - error_message = "Partial VPC inputs must stay disabled while a null KMS key keeps the static IAM statement on its inert sentinel ARN." + error_message = "Partial VPC inputs must stay disabled and a null KMS key must omit the KMS statement entirely." } } diff --git a/modules/runner-stack/job-retry/variables.tf b/modules/orchestration-providers/webhook/job-retry/variables.tf similarity index 97% rename from modules/runner-stack/job-retry/variables.tf rename to modules/orchestration-providers/webhook/job-retry/variables.tf index 69bf67f794..d6645c3bb6 100644 --- a/modules/runner-stack/job-retry/variables.tf +++ b/modules/orchestration-providers/webhook/job-retry/variables.tf @@ -1,6 +1,6 @@ variable "config" { description = <<-EOT - Provider-neutral job-retry configuration assembled by runner-stack. + Provider-neutral job-retry configuration assembled by runner-config. - `prefix`: Prefix used to name job-retry resources. - `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN. @@ -28,6 +28,7 @@ variable "config" { - `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. - `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `queue.build`: URL and ARN of the build queue to which retry messages are published. + - `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key. - `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. @@ -94,6 +95,7 @@ variable "config" { url = string arn = string }) + kms_key_id = optional(string, null) event_source_mapping = object({ batch_size = number maximum_batching_window_in_seconds = number diff --git a/modules/runner-stack/job-retry/versions.tf b/modules/orchestration-providers/webhook/job-retry/versions.tf similarity index 100% rename from modules/runner-stack/job-retry/versions.tf rename to modules/orchestration-providers/webhook/job-retry/versions.tf diff --git a/modules/orchestration-providers/webhook/main.tf b/modules/orchestration-providers/webhook/main.tf new file mode 100644 index 0000000000..44a67bc55e --- /dev/null +++ b/modules/orchestration-providers/webhook/main.tf @@ -0,0 +1,62 @@ +locals { + packaged_runners_lambda_zip = "${path.module}/../../../lambdas/functions/control-plane/runners.zip" + scale_artifact_s3_selected = var.config.lambda.scale.artifact.s3 != null + scale_artifact = { + zip = local.scale_artifact_s3_selected ? null : coalesce( + var.config.lambda.scale.artifact.zip, + local.packaged_runners_lambda_zip, + ) + s3 = { + bucket = local.scale_artifact_s3_selected ? var.lambda.artifact.s3.bucket : null + key = try(var.config.lambda.scale.artifact.s3.key, null) + object_version = try(var.config.lambda.scale.artifact.s3.object_version, null) + } + } + + resolved_config = { + prefix = var.prefix + tags = var.tags + runner = merge(var.runner, { + maximum_count = var.config.runner.maximum_count + }) + github = merge(var.github, var.config.github) + lambda = merge(var.lambda, { + artifact = local.scale_artifact + }) + queue = merge(var.config.queue, { + event_source_mapping = var.config.lambda.scale_up.event_source_mapping + }) + scale_up = var.config.lambda.scale_up + scale_down = var.config.lambda.scale_down + pool = var.config.lambda.pool + job_retry = var.config.job_retry + ssm = var.ssm + observability = var.observability + } + + common_tags = local.resolved_config.tags + lambda_tags = merge(local.common_tags, local.resolved_config.lambda.tags) + queue_tags = merge(local.common_tags, local.resolved_config.queue.tags) + observability_log_tags = merge(local.common_tags, local.resolved_config.observability.logs.tags) + + scale_up_tags = merge(local.common_tags, local.resolved_config.scale_up.tags) + scale_up_lambda_tags = merge(local.lambda_tags, local.resolved_config.scale_up.tags) + scale_up_log_tags = merge(local.observability_log_tags, local.resolved_config.scale_up.tags) + scale_up_queue_tags = merge(local.queue_tags, local.resolved_config.scale_up.tags) + + scale_down_tags = merge(local.common_tags, local.resolved_config.scale_down.tags) + scale_down_lambda_tags = merge(local.lambda_tags, local.resolved_config.scale_down.tags) + scale_down_log_tags = merge(local.observability_log_tags, local.resolved_config.scale_down.tags) + + pool_tags = merge(local.common_tags, local.resolved_config.pool.tags) + pool_lambda_tags = merge(local.lambda_tags, local.resolved_config.pool.tags) + pool_log_tags = merge(local.observability_log_tags, local.resolved_config.pool.tags) + + job_retry_enabled = local.resolved_config.job_retry.enabled + job_retry_tags = merge(local.common_tags, local.resolved_config.job_retry.tags) + job_retry_lambda_tags = merge(local.lambda_tags, local.resolved_config.job_retry.tags) + job_retry_log_tags = merge(local.observability_log_tags, local.resolved_config.job_retry.tags) + job_retry_queue_tags = merge(local.queue_tags, local.resolved_config.job_retry.tags) + + enable_job_queued_check = local.resolved_config.scale_up.job_queued_check_enabled == null ? !local.resolved_config.runner.ephemeral : local.resolved_config.scale_up.job_queued_check_enabled +} diff --git a/modules/orchestration-providers/webhook/outputs.tf b/modules/orchestration-providers/webhook/outputs.tf new file mode 100644 index 0000000000..4c5e1008f4 --- /dev/null +++ b/modules/orchestration-providers/webhook/outputs.tf @@ -0,0 +1,22 @@ +output "scale_up" { + description = "Scale-up control-plane resources." + value = module.scale_runners.scale_up +} + +output "scale_down" { + description = "Scale-down control-plane resources." + value = module.scale_runners.scale_down +} + +output "pool" { + description = "Scheduled pool resources. Null when no pool schedule is configured." + value = one(module.pool[*].pool) +} + +output "job_retry" { + description = "Job-retry resources. Null when job retry is disabled." + value = local.job_retry_enabled ? { + lambda = one(module.job_retry[*].lambda) + queue = one(module.job_retry[*].job_retry_check_queue) + } : null +} diff --git a/modules/orchestration-providers/webhook/pool.tf b/modules/orchestration-providers/webhook/pool.tf new file mode 100644 index 0000000000..e6d3d35d80 --- /dev/null +++ b/modules/orchestration-providers/webhook/pool.tf @@ -0,0 +1,65 @@ +module "pool" { + count = length(local.resolved_config.pool.config) > 0 ? 1 : 0 + source = "./pool" + + config = { + prefix = local.resolved_config.prefix + ghes = { + ssl_verify = local.resolved_config.github.enterprise_server.ssl_verify + url = local.resolved_config.github.enterprise_server.url + } + user_agent = local.resolved_config.github.user_agent + github_app_parameters = local.resolved_config.github.app_parameters + runners_maximum_count = local.resolved_config.runner.maximum_count + kms_key_id = local.resolved_config.ssm.kms_key_id + lambda = { + log_level = local.resolved_config.observability.logs.level + logging_retention_in_days = local.resolved_config.observability.logs.retention_in_days + logging_kms_key_id = local.resolved_config.observability.logs.kms_key_id + log_class = local.resolved_config.observability.logs.class + reserved_concurrent_executions = local.resolved_config.pool.reserved_concurrent_executions + s3_bucket = local.resolved_config.lambda.artifact.s3.bucket + s3_key = local.resolved_config.lambda.artifact.s3.key + s3_object_version = local.resolved_config.lambda.artifact.s3.object_version + security_group_ids = local.resolved_config.lambda.security_group_ids + subnet_ids = local.resolved_config.lambda.subnet_ids + architecture = local.resolved_config.lambda.architecture + memory_size = local.resolved_config.pool.memory_size + runtime = local.resolved_config.lambda.runtime + timeout = local.resolved_config.pool.timeout + zip = local.resolved_config.lambda.artifact.zip + parameter_store_tags = local.resolved_config.ssm.parameter_store_tags + principals = local.resolved_config.lambda.role.principals + } + pool = local.resolved_config.pool.config + include_busy_runners = local.resolved_config.pool.include_busy_runners + role_path = local.resolved_config.lambda.role.path + role_permissions_boundary = local.resolved_config.lambda.role.permissions_boundary + runner = { + disable_runner_autoupdate = local.resolved_config.runner.auto_update_disabled + ephemeral = local.resolved_config.runner.ephemeral + enable_jit_config = local.resolved_config.runner.jit_config_enabled + labels = local.resolved_config.runner.labels + group_name = local.resolved_config.runner.group_name + name_prefix = local.resolved_config.runner.name_prefix + pool_owner = local.resolved_config.pool.runner_owner + } + ssm_token_path = local.resolved_config.ssm.token_path + ssm_token_path_arn = local.resolved_config.ssm.token_path_arn + ssm_config_path = local.resolved_config.ssm.config_path + tags = local.pool_tags + lambda_tags = local.pool_lambda_tags + log_group_tags = local.pool_log_tags + arn_ssm_parameters_path_config = local.resolved_config.ssm.config_path_arn + } + + aws_partition = var.aws_partition + tracing_config = local.resolved_config.observability.tracing + runner_provider = { + type = var.runner_provider.type + environment_variables = var.runner_provider.pool.environment_variables + iam_policy_json = var.runner_provider.pool.iam_policy_json + managed_policy_enabled = var.runner_provider.pool.managed_policy_enabled + managed_policy_arn = var.runner_provider.pool.managed_policy_arn + } +} diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md new file mode 100644 index 0000000000..7b537f7167 --- /dev/null +++ b/modules/orchestration-providers/webhook/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.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 | + +## 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 webhook orchestration provider 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = 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 | +| ---- | ----------- | +| [pool](#output\_pool) | Scheduled pool Lambda resources. | + diff --git a/modules/runner-stack/pool/iam-policies.tf b/modules/orchestration-providers/webhook/pool/iam-policies.tf similarity index 61% rename from modules/runner-stack/pool/iam-policies.tf rename to modules/orchestration-providers/webhook/pool/iam-policies.tf index 840c5360b8..f5a9285bce 100644 --- a/modules/runner-stack/pool/iam-policies.tf +++ b/modules/orchestration-providers/webhook/pool/iam-policies.tf @@ -1,6 +1,7 @@ # IAM policies attached to the pool Lambda role. data "aws_iam_policy_document" "pool_common" { statement { + sid = "WebhookPoolWriteRuntimeParameters" effect = "Allow" actions = [ @@ -8,10 +9,16 @@ data "aws_iam_policy_document" "pool_common" { "ssm:PutParameter", ] - resources = ["*"] + resources = [ + var.config.ssm_token_path_arn, + "${var.config.ssm_token_path_arn}/*", + var.config.arn_ssm_parameters_path_config, + "${var.config.arn_ssm_parameters_path_config}/*", + ] } statement { + sid = "WebhookPoolReadRunnerConfigParameters" effect = "Allow" actions = [ @@ -27,6 +34,7 @@ data "aws_iam_policy_document" "pool_common" { } statement { + sid = "WebhookPoolReadGitHubAppParameters" effect = "Allow" actions = [ @@ -41,18 +49,22 @@ data "aws_iam_policy_document" "pool_common" { ) } - statement { - effect = "Allow" - actions = ["kms:Decrypt"] - resources = [coalesce( - var.config.kms_key_id, - "arn:${var.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", - )] + dynamic "statement" { + for_each = var.config.kms_key_id == null ? [] : [var.config.kms_key_id] + iterator = kms_key + + content { + sid = "WebhookPoolDecryptParameterStore" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [kms_key.value] + } } } data "aws_iam_policy_document" "pool_logging" { statement { + sid = "WebhookPoolWriteLogs" effect = "Allow" actions = [ diff --git a/modules/runner-stack/pool/outputs.tf b/modules/orchestration-providers/webhook/pool/outputs.tf similarity index 100% rename from modules/runner-stack/pool/outputs.tf rename to modules/orchestration-providers/webhook/pool/outputs.tf diff --git a/modules/runner-stack/pool/pool.tf b/modules/orchestration-providers/webhook/pool/pool.tf similarity index 99% rename from modules/runner-stack/pool/pool.tf rename to modules/orchestration-providers/webhook/pool/pool.tf index d02d058431..200c3fbc1c 100644 --- a/modules/runner-stack/pool/pool.tf +++ b/modules/orchestration-providers/webhook/pool/pool.tf @@ -139,7 +139,7 @@ resource "aws_iam_role_policy_attachment" "provider" { policy_arn = var.runner_provider.managed_policy_arn } -# lambda xray policy +# AWS X-Ray write/read trace APIs do not support resource-level permissions. data "aws_iam_policy_document" "lambda_xray" { count = var.tracing_config.mode != null ? 1 : 0 statement { diff --git a/modules/runner-stack/pool/tests/provider.tftest.hcl b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl similarity index 70% rename from modules/runner-stack/pool/tests/provider.tftest.hcl rename to modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl index bfbd59091d..1cd352ded4 100644 --- a/modules/runner-stack/pool/tests/provider.tftest.hcl +++ b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl @@ -87,6 +87,7 @@ variables { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/pool-test" role_path = "/" ssm_token_path = "/github-runner/tokens" + ssm_token_path_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/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 = {} @@ -109,6 +110,12 @@ variables { managed_policy_enabled = true managed_policy_arn = "arn:aws:iam::123456789012:policy/microvm-pool" } + + tracing_config = { + mode = "Active" + capture_http_requests = true + capture_error = true + } } run "provider_supplies_only_compute_specific_pool_configuration" { @@ -128,8 +135,11 @@ run "provider_supplies_only_compute_specific_pool_configuration" { } 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." + condition = ( + aws_lambda_function.pool.environment[0].variables["RUNNER_OWNER"] == "example" + && aws_lambda_function.pool.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "10" + ) + error_message = "The pool module must assemble common runner registration values and the webhook-provider capacity limit." } assert { @@ -167,13 +177,70 @@ run "provider_supplies_only_compute_specific_pool_configuration" { assert { condition = ( length(data.aws_iam_policy_document.pool_common.statement) == 4 - && data.aws_iam_policy_document.pool_common.statement[3].resources == toset(["arn:aws:kms:eu-west-1:123456789012:key/pool-test"]) + && one([ + for statement in data.aws_iam_policy_document.pool_common.statement : statement + if statement.sid == "WebhookPoolDecryptParameterStore" + ]).resources == toset(["arn:aws:kms:eu-west-1:123456789012:key/pool-test"]) ) error_message = "The pool KMS policy statement must consume the scalar key ARN." } + assert { + condition = ( + one([ + for statement in data.aws_iam_policy_document.pool_common.statement : statement + if statement.sid == "WebhookPoolWriteRuntimeParameters" + ]).resources == toset([ + "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/tokens", + "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/tokens/*", + "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/config", + "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/config/*", + ]) + && !contains(one([ + for statement in data.aws_iam_policy_document.pool_common.statement : statement + if statement.sid == "WebhookPoolWriteRuntimeParameters" + ]).resources, "*") + ) + error_message = "The pool Lambda must scope runtime SSM writes to the token and runner-config parameter paths." + } + + assert { + condition = ( + data.aws_iam_policy_document.lambda_xray[0].statement[0].sid == "AllowXRay" + && data.aws_iam_policy_document.lambda_xray[0].statement[0].resources == toset(["*"]) + && toset(data.aws_iam_policy_document.lambda_xray[0].statement[0].actions) == toset([ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments", + ]) + ) + error_message = "Only the resource-agnostic X-Ray APIs may retain a wildcard resource in the pool policies." + } + 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." } } + +run "omits_optional_kms_statement" { + command = plan + + variables { + config = merge(var.config, { + kms_key_id = null + }) + } + + assert { + condition = ( + length(data.aws_iam_policy_document.pool_common.statement) == 3 + && length([ + for statement in data.aws_iam_policy_document.pool_common.statement : statement + if anytrue([for action in statement.actions : startswith(action, "kms:")]) + ]) == 0 + ) + error_message = "A null Parameter Store key must omit the optional pool KMS statement." + } +} diff --git a/modules/runner-stack/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf similarity index 95% rename from modules/runner-stack/pool/variables.tf rename to modules/orchestration-providers/webhook/pool/variables.tf index f0cbfb556b..905c823db7 100644 --- a/modules/runner-stack/pool/variables.tf +++ b/modules/orchestration-providers/webhook/pool/variables.tf @@ -1,6 +1,6 @@ variable "config" { description = <<-EOF - Configuration passed from the runner stack to the pool Lambda and scheduler. + Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler. - `lambda`: Pool Lambda runtime and deployment configuration. - `lambda.log_level`: Logging level used by the pool Lambda. @@ -36,7 +36,7 @@ variable "config" { - `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. + - `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda. - `prefix`: Prefix used to name pool resources. - `pool`: Scheduled pool targets. - `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target. @@ -44,9 +44,10 @@ variable "config" { - `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters. The ARN may be unknown until apply; IAM policy shape remains static. + - `kms_key_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters. - `role_path`: IAM path applied to roles created for the pool. - `ssm_token_path`: SSM path under which runner registration tokens are stored. + - `ssm_token_path_arn`: ARN matching the runner registration-token SSM path. - `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. @@ -107,6 +108,7 @@ variable "config" { kms_key_id = optional(string, null) role_path = string ssm_token_path = string + ssm_token_path_arn = string ssm_config_path = string arn_ssm_parameters_path_config = string lambda_tags = map(string) diff --git a/modules/runner-stack/pool/versions.tf b/modules/orchestration-providers/webhook/pool/versions.tf similarity index 100% rename from modules/runner-stack/pool/versions.tf rename to modules/orchestration-providers/webhook/pool/versions.tf diff --git a/modules/runner-stack/scale-down-state-diagram.md b/modules/orchestration-providers/webhook/scale-down-state-diagram.md similarity index 100% rename from modules/runner-stack/scale-down-state-diagram.md rename to modules/orchestration-providers/webhook/scale-down-state-diagram.md diff --git a/modules/orchestration-providers/webhook/scale-runners.tf b/modules/orchestration-providers/webhook/scale-runners.tf new file mode 100644 index 0000000000..caf79eefb5 --- /dev/null +++ b/modules/orchestration-providers/webhook/scale-runners.tf @@ -0,0 +1,61 @@ +module "scale_runners" { + source = "./scale-runners" + + aws_partition = var.aws_partition + + config = { + prefix = local.resolved_config.prefix + lambda = { + artifact = local.resolved_config.lambda.artifact + runtime = local.resolved_config.lambda.runtime + architecture = local.resolved_config.lambda.architecture + vpc = { + subnet_ids = local.resolved_config.lambda.subnet_ids + security_group_ids = local.resolved_config.lambda.security_group_ids + } + role = local.resolved_config.lambda.role + } + runner = local.resolved_config.runner + github = local.resolved_config.github + queue = { + build = local.resolved_config.queue.build + kms_key_id = local.resolved_config.queue.kms_key_id + event_source_mapping = local.resolved_config.queue.event_source_mapping + } + ssm = local.resolved_config.ssm + observability = { + logs = local.resolved_config.observability.logs + tracing = local.resolved_config.observability.tracing + metrics = local.resolved_config.observability.metrics + } + scale_up = merge(local.resolved_config.scale_up, { + 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 = merge(local.resolved_config.scale_down, { + 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 = local.resolved_config.job_retry.max_attempts + delay_in_seconds = local.resolved_config.job_retry.delay_in_seconds + delay_backoff = local.resolved_config.job_retry.delay_backoff + queue = one(module.job_retry[*].job_retry_check_queue) + } + } + + runner_provider = { + type = var.runner_provider.type + scale_up = var.runner_provider.scale_up + scale_down = var.runner_provider.scale_down + } +} diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md new file mode 100644 index 0000000000..9b8e7f9426 --- /dev/null +++ b/modules/orchestration-providers/webhook/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-config` supplies common configuration through the webhook orchestration provider together with the selected compute provider's environment and IAM fragments. + +The module is an implementation detail of the experimental runner configuration. It is composed by the webhook orchestration provider 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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume 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`: Webhook-provider runner capacity limit for this runner configuration.
- `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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = 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
})
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/orchestration-providers/webhook/scale-runners/common-config.tf similarity index 100% rename from modules/runner-stack/scale-runners/common-config.tf rename to modules/orchestration-providers/webhook/scale-runners/common-config.tf diff --git a/modules/runner-stack/scale-runners/lambda-iam-policies.tf b/modules/orchestration-providers/webhook/scale-runners/lambda-iam-policies.tf similarity index 90% rename from modules/runner-stack/scale-runners/lambda-iam-policies.tf rename to modules/orchestration-providers/webhook/scale-runners/lambda-iam-policies.tf index 8568a0f3bc..0c9214d0d3 100644 --- a/modules/runner-stack/scale-runners/lambda-iam-policies.tf +++ b/modules/orchestration-providers/webhook/scale-runners/lambda-iam-policies.tf @@ -21,6 +21,7 @@ data "aws_iam_policy_document" "lambda_assume_role" { data "aws_iam_policy_document" "lambda_xray" { count = var.config.observability.tracing.mode != null ? 1 : 0 + # AWS X-Ray write/read trace APIs do not support resource-level permissions. statement { sid = "AllowXRay" effect = "Allow" diff --git a/modules/runner-stack/scale-runners/outputs.tf b/modules/orchestration-providers/webhook/scale-runners/outputs.tf similarity index 100% rename from modules/runner-stack/scale-runners/outputs.tf rename to modules/orchestration-providers/webhook/scale-runners/outputs.tf diff --git a/modules/runner-stack/scale-runners/scale-down-iam-policies.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf similarity index 68% rename from modules/runner-stack/scale-runners/scale-down-iam-policies.tf rename to modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf index 19bb40cdc3..b95cb9e686 100644 --- a/modules/runner-stack/scale-runners/scale-down-iam-policies.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf @@ -1,5 +1,6 @@ data "aws_iam_policy_document" "scale_down_common" { statement { + sid = "WebhookScaleDownReadGitHubAppParameters" effect = "Allow" actions = [ "ssm:GetParameter", @@ -12,13 +13,16 @@ data "aws_iam_policy_document" "scale_down_common" { ) } - statement { - effect = "Allow" - actions = ["kms:Decrypt"] - resources = [coalesce( - var.config.ssm.kms_key_id, - "arn:${var.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", - )] + dynamic "statement" { + for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + iterator = kms_key + + content { + sid = "WebhookScaleDownDecryptParameterStore" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [kms_key.value] + } } } @@ -31,6 +35,7 @@ data "aws_iam_policy_document" "scale_down" { data "aws_iam_policy_document" "scale_down_logging" { statement { + sid = "WebhookScaleDownWriteLogs" effect = "Allow" actions = [ "logs:CreateLogStream", diff --git a/modules/runner-stack/scale-runners/scale-down.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf similarity index 100% rename from modules/runner-stack/scale-runners/scale-down.tf rename to modules/orchestration-providers/webhook/scale-runners/scale-down.tf diff --git a/modules/runner-stack/scale-runners/scale-up-iam-policies.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf similarity index 55% rename from modules/runner-stack/scale-runners/scale-up-iam-policies.tf rename to modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf index 8aa4d8674f..b3c87b8ad7 100644 --- a/modules/runner-stack/scale-runners/scale-up-iam-policies.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf @@ -1,14 +1,21 @@ data "aws_iam_policy_document" "scale_up_common" { statement { + sid = "WebhookScaleUpWriteRuntimeParameters" effect = "Allow" actions = [ "ssm:PutParameter", "ssm:AddTagsToResource", ] - resources = ["*"] + resources = [ + var.config.ssm.token_path_arn, + "${var.config.ssm.token_path_arn}/*", + var.config.ssm.config_path_arn, + "${var.config.ssm.config_path_arn}/*", + ] } statement { + sid = "WebhookScaleUpReadGitHubAppAndRunnerConfigParameters" effect = "Allow" actions = [ "ssm:GetParameter", @@ -18,11 +25,15 @@ data "aws_iam_policy_document" "scale_up_common" { [for p in var.config.github.app_parameters.id : p.arn], [for p in var.config.github.app_parameters.key_base64 : p.arn], [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], - ["${var.config.ssm.config_path_arn}/*"], + [ + var.config.ssm.config_path_arn, + "${var.config.ssm.config_path_arn}/*", + ], ) } statement { + sid = "WebhookScaleUpConsumeBuildQueue" effect = "Allow" actions = [ "sqs:ReceiveMessage", @@ -32,13 +43,28 @@ data "aws_iam_policy_document" "scale_up_common" { resources = [var.config.queue.build.arn] } - statement { - effect = "Allow" - actions = ["kms:Decrypt"] - resources = [coalesce( - var.config.ssm.kms_key_id, - "arn:${var.aws_partition}:kms:*:000000000000:key/00000000-0000-0000-0000-000000000000", - )] + dynamic "statement" { + for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + iterator = kms_key + + content { + sid = "WebhookScaleUpDecryptParameterStore" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [kms_key.value] + } + } + + dynamic "statement" { + for_each = var.config.queue.kms_key_id == null ? [] : [var.config.queue.kms_key_id] + iterator = kms_key + + content { + sid = "WebhookScaleUpDecryptBuildQueue" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [kms_key.value] + } } } @@ -51,6 +77,7 @@ data "aws_iam_policy_document" "scale_up" { data "aws_iam_policy_document" "scale_up_logging" { statement { + sid = "WebhookScaleUpWriteLogs" effect = "Allow" actions = [ "logs:CreateLogStream", @@ -64,6 +91,7 @@ data "aws_iam_policy_document" "scale_up_job_retry_publish" { count = var.config.job_retry.enabled ? 1 : 0 statement { + sid = "WebhookScaleUpPublishJobRetryQueue" effect = "Allow" actions = [ "sqs:SendMessage", diff --git a/modules/runner-stack/scale-runners/scale-up.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf similarity index 100% rename from modules/runner-stack/scale-runners/scale-up.tf rename to modules/orchestration-providers/webhook/scale-runners/scale-up.tf diff --git a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl similarity index 77% rename from modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl rename to modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl index b8bf32c569..16e4c4bffa 100644 --- a/modules/runner-stack/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl @@ -92,6 +92,7 @@ variables { build = { arn = "arn:aws-us-gov:sqs:us-gov-west-1:123456789012:build-queue" } + kms_key_id = "arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/build-queue-test" event_source_mapping = { batch_size = 25 maximum_batching_window_in_seconds = 5 @@ -99,6 +100,7 @@ variables { } ssm = { token_path = "/github-runner/tokens" + token_path_arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/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([{ @@ -233,6 +235,7 @@ run "assembles_provider_neutral_scaling_control_plane" { condition = ( aws_lambda_function.scale_up.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "microvm" && aws_lambda_function.scale_down.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "microvm" + && aws_lambda_function.scale_up.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "7" && 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") @@ -345,12 +348,90 @@ run "assembles_provider_neutral_scaling_control_plane" { 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_up_common.statement) == 5 && length(data.aws_iam_policy_document.scale_down_common.statement) == 2 - && data.aws_iam_policy_document.scale_up_common.statement[3].resources == toset(["arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test"]) - && data.aws_iam_policy_document.scale_down_common.statement[1].resources == toset(["arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test"]) + && one([ + for statement in data.aws_iam_policy_document.scale_up_common.statement : statement + if statement.sid == "WebhookScaleUpDecryptParameterStore" + ]).resources == toset(["arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test"]) + && one([ + for statement in data.aws_iam_policy_document.scale_up_common.statement : statement + if statement.sid == "WebhookScaleUpDecryptBuildQueue" + ]).resources == toset(["arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/build-queue-test"]) + && one([ + for statement in data.aws_iam_policy_document.scale_up_common.statement : statement + if statement.sid == "WebhookScaleUpDecryptBuildQueue" + ]).actions == toset(["kms:Decrypt"]) + && one([ + for statement in data.aws_iam_policy_document.scale_down_common.statement : statement + if statement.sid == "WebhookScaleDownDecryptParameterStore" + ]).resources == toset(["arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/scale-runners-test"]) && 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." + error_message = "Common, provider, distinct Parameter Store/build-queue KMS, and retry IAM fragments must retain their conditional plan shape." + } + + assert { + condition = ( + one([ + for statement in data.aws_iam_policy_document.scale_up_common.statement : statement + if statement.sid == "WebhookScaleUpWriteRuntimeParameters" + ]).resources == toset([ + "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/tokens", + "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/tokens/*", + "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/config", + "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/config/*", + ]) + && !contains(one([ + for statement in data.aws_iam_policy_document.scale_up_common.statement : statement + if statement.sid == "WebhookScaleUpWriteRuntimeParameters" + ]).resources, "*") + ) + error_message = "Scale-up must scope runtime SSM writes to the token and runner-config parameter paths." + } + + assert { + condition = ( + data.aws_iam_policy_document.lambda_xray[0].statement[0].sid == "AllowXRay" + && data.aws_iam_policy_document.lambda_xray[0].statement[0].resources == toset(["*"]) + && toset(data.aws_iam_policy_document.lambda_xray[0].statement[0].actions) == toset([ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments", + ]) + ) + error_message = "Only the resource-agnostic X-Ray APIs may retain a wildcard resource in the scale-runner common policies." + } +} + +run "omits_optional_kms_statements" { + command = plan + + variables { + config = merge(var.config, { + queue = merge(var.config.queue, { + kms_key_id = null + }) + ssm = merge(var.config.ssm, { + kms_key_id = null + }) + }) + } + + assert { + condition = ( + length(data.aws_iam_policy_document.scale_up_common.statement) == 3 + && length(data.aws_iam_policy_document.scale_down_common.statement) == 1 + && length([ + for statement in data.aws_iam_policy_document.scale_up_common.statement : statement + if anytrue([for action in statement.actions : startswith(action, "kms:")]) + ]) == 0 + && length([ + for statement in data.aws_iam_policy_document.scale_down_common.statement : statement + if anytrue([for action in statement.actions : startswith(action, "kms:")]) + ]) == 0 + ) + error_message = "Null Parameter Store and build-queue keys must omit every optional scale-runner KMS statement." } } diff --git a/modules/runner-stack/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf similarity index 95% rename from modules/runner-stack/scale-runners/variables.tf rename to modules/orchestration-providers/webhook/scale-runners/variables.tf index 2fc3af5ff2..0e88af4e5b 100644 --- a/modules/runner-stack/scale-runners/variables.tf +++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf @@ -6,7 +6,7 @@ variable "aws_partition" { variable "config" { description = <<-EOT - Provider-neutral scale-up and scale-down configuration assembled by runner-stack. + Provider-neutral scale-up and scale-down configuration assembled by runner-config. - `prefix`: Prefix used to name scaling resources. - `lambda.artifact.zip`: Resolved local control-plane archive. @@ -27,7 +27,7 @@ variable "config" { - `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. + - `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration. - `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. @@ -36,9 +36,11 @@ variable "config" { - `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. - `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `queue.build.arn`: ARN of the build queue consumed by scale-up. + - `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key. - `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply. @@ -115,6 +117,7 @@ variable "config" { build = object({ arn = string }) + kms_key_id = optional(string, null) event_source_mapping = object({ batch_size = number maximum_batching_window_in_seconds = number @@ -122,6 +125,7 @@ variable "config" { }) ssm = object({ token_path = string + token_path_arn = string config_path = string config_path_arn = string parameter_store_tags = string diff --git a/modules/runner-stack/scale-runners/versions.tf b/modules/orchestration-providers/webhook/scale-runners/versions.tf similarity index 100% rename from modules/runner-stack/scale-runners/versions.tf rename to modules/orchestration-providers/webhook/scale-runners/versions.tf diff --git a/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl new file mode 100644 index 0000000000..1a0db2880d --- /dev/null +++ b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl @@ -0,0 +1,278 @@ +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/webhook-orchestration-test" + } + } +} + +variables { + prefix = "webhook-test" + + tags = { + Scope = "common" + Precedence = "common" + } + + runner = { + os = "linux" + auto_update_disabled = false + ephemeral = true + jit_config_enabled = true + labels = ["self-hosted", "linux"] + group_name = "default" + name_prefix = "webhook-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" + }] + installation_id = [null] + } + enterprise_server = { + url = null + ssl_verify = true + } + user_agent = "webhook-orchestration-test" + } + + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + runtime = "nodejs24.x" + architecture = "arm64" + subnet_ids = [] + security_group_ids = [] + tags = { + Lambda = "yes" + Precedence = "lambda" + } + role = { + path = "/webhook-test/" + } + } + + ssm = { + token_path = "/github-runner/tokens" + token_path_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/tokens" + config_path = "/github-runner/config" + config_path_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/config" + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/webhook-test" + parameter_store_tags = "[]" + } + + observability = { + logs = { + level = "info" + retention_in_days = 14 + kms_key_id = null + class = "STANDARD" + } + tracing = { + mode = null + capture_http_requests = false + capture_error = false + } + metrics = { + enable = true + namespace = "WebhookTest" + metric = { + enable_github_app_rate_limit = true + enable_job_retry = true + } + } + } + + config = { + runner = { + maximum_count = 10 + } + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/build-queue-test" + tags = { + Queue = "yes" + } + } + lambda = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + scale_up = { + memory_size = 512 + timeout = 60 + reserved_concurrent_executions = 1 + job_queued_check_enabled = null + event_source_mapping = { + batch_size = 10 + maximum_batching_window_in_seconds = 0 + } + tags = { + ScaleUp = "yes" + Precedence = "scale-up" + } + } + scale_down = { + memory_size = 512 + timeout = 60 + schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = null + idle_config = [] + tags = { + ScaleDown = "yes" + } + } + pool = { + memory_size = 512 + timeout = 60 + reserved_concurrent_executions = 1 + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + schedule_expression_timezone = "UTC" + size = 1 + }] + include_busy_runners = false + runner_owner = "example" + tags = { + Pool = "yes" + } + } + } + job_retry = { + enabled = true + delay_in_seconds = 300 + delay_backoff = 2 + max_attempts = 2 + tags = { + JobRetry = "yes" + } + lambda = { + memory_size = 256 + reserved_concurrent_executions = 1 + timeout = 30 + } + } + } + + runner_provider = { + type = "test-provider" + scale_up = { + environment_variables = { + TEST_SCALE_UP = "yes" + } + iam_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + additional_iam_policy_json = null + managed_policy = null + } + scale_down = { + environment_variables = { + TEST_SCALE_DOWN = "yes" + } + iam_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + pool = { + environment_variables = { + TEST_POOL = "yes" + } + iam_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + managed_policy_enabled = false + managed_policy_arn = null + } + } +} + +run "owns_webhook_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 = "The webhook provider must own and expose both scaling functions." + } + + assert { + condition = ( + output.pool != null + && output.job_retry != null + && output.job_retry.lambda != null + && output.job_retry.queue != null + ) + error_message = "The webhook provider must own the optional pool and job-retry resources when enabled." + } + + assert { + condition = ( + output.scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "test-provider" + && output.scale_up.lambda.environment[0].variables["TEST_SCALE_UP"] == "yes" + && output.scale_down.lambda.environment[0].variables["TEST_SCALE_DOWN"] == "yes" + && output.pool.lambda.environment[0].variables["TEST_POOL"] == "yes" + ) + error_message = "The webhook provider must forward each compute-provider capability to the matching leaf." + } + + assert { + condition = ( + output.scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "10" + && output.pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "10" + ) + error_message = "The webhook provider must route its provider-owned runner capacity limit to scale-up and pool without reading it from common runner values." + } + + assert { + condition = ( + output.scale_up.lambda.s3_bucket == "lambda-artifacts" + && output.scale_up.lambda.s3_key == "runners.zip" + && output.scale_down.lambda.s3_bucket == "lambda-artifacts" + && output.pool.lambda.s3_key == "runners.zip" + ) + error_message = "The webhook provider must combine the common artifact bucket with its provider-owned scale artifact key." + } + + assert { + condition = ( + output.scale_up.lambda.tags["Scope"] == "common" + && output.scale_up.lambda.tags["Lambda"] == "yes" + && output.scale_up.lambda.tags["ScaleUp"] == "yes" + && output.scale_up.lambda.tags["Precedence"] == "scale-up" + && output.job_retry.queue.tags["JobRetry"] == "yes" + && output.job_retry.queue.tags["Queue"] == "yes" + ) + error_message = "Provider-owned normalization must preserve common, substrate, and webhook component tag precedence." + } + + assert { + condition = ( + length(module.pool) == 1 + && length(module.job_retry) == 1 + ) + error_message = "Pool and job-retry leaf ownership must remain inside the webhook provider." + } +} diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf new file mode 100644 index 0000000000..74f0935961 --- /dev/null +++ b/modules/orchestration-providers/webhook/variables.tf @@ -0,0 +1,222 @@ +variable "aws_partition" { + description = "AWS partition used to construct ARNs." + type = string + default = "aws" +} + +variable "prefix" { + description = "Prefix used to identify resources created for this webhook orchestration provider." + type = string +} + +variable "tags" { + description = "Base tags available to webhook-provider resources. Component-specific tags override this map within their documented scopes." + type = map(string) + default = {} +} + +variable "config" { + description = "Resolved provider-owned values from orchestration.webhook, including the runner capacity limit used by scaling and pool controls." + type = object({ + runner = object({ + maximum_count = number + }) + github = object({ + organization_runners = bool + }) + queue = object({ + build = object({ + arn = string + url = string + }) + kms_key_id = optional(string, null) + tags = optional(map(string), {}) + }) + lambda = object({ + scale = object({ + artifact = object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }) + }) + scale_up = object({ + memory_size = number + timeout = number + reserved_concurrent_executions = number + job_queued_check_enabled = optional(bool, null) + event_source_mapping = object({ + batch_size = number + maximum_batching_window_in_seconds = number + }) + tags = optional(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 = optional(map(string), {}) + }) + pool = object({ + memory_size = number + timeout = number + reserved_concurrent_executions = number + config = list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })) + include_busy_runners = bool + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }) + }) + job_retry = object({ + enabled = bool + delay_in_seconds = number + delay_backoff = number + max_attempts = number + tags = optional(map(string), {}) + lambda = object({ + memory_size = number + reserved_concurrent_executions = number + timeout = number + }) + }) + }) + nullable = false + + validation { + condition = !( + var.config.lambda.scale.artifact.zip != null && + var.config.lambda.scale.artifact.s3 != null + ) + error_message = "config.lambda.scale.artifact must select at most one of zip or s3." + } +} + +variable "runner" { + description = "Common runner registration values consumed by webhook demand controls. Capacity remains provider-owned under config.runner." + type = object({ + os = string + auto_update_disabled = bool + ephemeral = bool + jit_config_enabled = optional(bool, null) + labels = list(string) + group_name = string + name_prefix = string + }) +} + +variable "github" { + description = "Common GitHub API client and GitHub App Parameter Store references." + type = object({ + app_parameters = object({ + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) + }) + enterprise_server = object({ + url = optional(string, null) + ssl_verify = bool + }) + user_agent = optional(string, null) + }) +} + +variable "lambda" { + description = "Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection." + type = object({ + artifact = object({ + s3 = object({ + bucket = optional(string, null) + }) + }) + runtime = string + architecture = string + subnet_ids = list(string) + security_group_ids = list(string) + tags = optional(map(string), {}) + role = object({ + path = string + permissions_boundary = optional(string, null) + principals = optional(list(object({ + type = string + identifiers = list(string) + })), []) + }) + }) +} + +variable "ssm" { + description = "Resolved Parameter Store paths, optional decrypt key, and runtime parameter tags." + type = object({ + token_path = string + token_path_arn = string + config_path = string + config_path_arn = string + kms_key_id = optional(string, null) + parameter_store_tags = string + }) +} + +variable "observability" { + description = "Common logging, tracing, and metrics configuration consumed by webhook controls." + type = object({ + logs = object({ + level = string + retention_in_days = number + kms_key_id = optional(string, null) + class = string + tags = optional(map(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 + }) + }) + }) +} + +variable "runner_provider" { + description = "Selected compute-provider capabilities consumed by webhook scaling, pool, and retry controls." + 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 + }) + pool = object({ + environment_variables = map(string) + iam_policy_json = string + managed_policy_enabled = bool + managed_policy_arn = optional(string, null) + }) + }) + nullable = false +} diff --git a/modules/runner-stack/ssm-housekeeper/versions.tf b/modules/orchestration-providers/webhook/versions.tf similarity index 100% rename from modules/runner-stack/ssm-housekeeper/versions.tf rename to modules/orchestration-providers/webhook/versions.tf diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md new file mode 100644 index 0000000000..289a9dd1e4 --- /dev/null +++ b/modules/runner-config/README.md @@ -0,0 +1,130 @@ +# Runner configuration module + +> This module is treated as an internal module; breaking changes do not trigger a major release bump. + +This internal module implements the experimental provider-neutral runner configuration selected by `experimental.multi_runner_config`. 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 module selects the [`webhook` orchestration provider](../orchestration-providers/webhook), which owns its [`scale-runners`](../orchestration-providers/webhook/scale-runners), [`pool`](../orchestration-providers/webhook/pool), and [`job-retry`](../orchestration-providers/webhook/job-retry) leaves. The configuration module retains the common [`ssm-housekeeper`](./ssm-housekeeper), creates or selects the runner IAM role, manages shared runner configuration in SSM, and dispatches the selected compute provider. + +Runner demand orchestration is selected independently through `orchestration`. `orchestration.webhook` is the currently supported provider and owns the build queue reference, `orchestration.webhook.runner.maximum_count` capacity limit, runner registration scope, scaling controls, scheduled pool, and job retry. Common `runner` contains no demand-provider capacity setting. The provider wrapper is nullable so a future sibling provider can be added without moving this webhook contract again, while validation requires exactly one provider to be selected. + +Common `lambda` contains only shared execution substrate and the optional shared artifact bucket. The webhook provider owns its scale-control archive selection at `orchestration.webhook.lambda.scale.artifact` and combines its zip or S3 key/version with that common substrate. The common SSM housekeeper independently owns `ssm.housekeeper.lambda.artifact`: an S3 selection combines its component key/version with the common bucket, a local zip is used otherwise when configured, and the packaged runner control-plane archive is the final fallback. It never inherits the webhook scale archive. + +Provider-owned settings remain nested under a typed provider block. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. `multi-runner` resolves experimental globals and runner-configuration overrides first, then its final forwarding adapter preserves the wrapped `{ ec2 = ... }` object expected by this module. Exactly one provider block must be non-null, and the configuration module derives its provider type from that block rather than from a separate discriminator. + +The EC2 block reaches runner-config with `compute_provider.ec2.binaries_syncer = { enabled, s3 }`; the S3 object is null when synchronization is disabled. Binary discovery and this shape adaptation happen in `multi-runner`, not inside runner-config. Before creating the common runner role, the configuration module calls the selected provider's isolated `trust-policy` module to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full compute-provider module, which receives the resolved runner role only after it is created. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common configuration module attaches each returned policy group to its runner or webhook-provider role. Provider-specific outputs remain grouped under the matching provider key, such as `provider.ec2`. EC2 is the only implemented Terraform compute provider in this phase. + +## Tagging + +`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `orchestration.webhook.queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `orchestration.webhook.lambda.scale_up`, `orchestration.webhook.lambda.scale_down`, `orchestration.webhook.lambda.pool`, `orchestration.webhook.job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes. + +Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `orchestration.webhook.lambda.scale_up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `orchestration.webhook.lambda.scale_up.tags`. + +Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and runner-configuration `compute_provider.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. + +## 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 `orchestration.webhook.lambda.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/orchestration-providers/webhook/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.33 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | +| [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | +| [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | +| [webhook](#module\_webhook) | ../orchestration-providers/webhook | n/a | + +## 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 | +| [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 | + +## 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.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 runner-configuration 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 configuration 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`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | +| [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the 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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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({
artifact = optional(object({
s3 = optional(object({
bucket = 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), {})
principals = optional(list(object({
type = string
identifiers = list(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 | +| [orchestration](#input\_orchestration) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null.

`webhook` is the currently supported provider. It owns the build queue reference, scale-control
artifact selection, runner capacity limit, scale-up, scale-down, scheduled pool, and job-retry
controls. Wrapper presence selects the provider and must therefore be known during planning. Future
providers can be added as sibling blocks without moving the webhook contract again. |
object({
webhook = optional(object({
runner = optional(object({
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})
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)
}), {})
}), {})
}), null)
})
| n/a | yes | +| [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no | +| [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.
- `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | +| [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.

- `paths.root`: Root Parameter Store path for this runner configuration.
- `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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `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.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `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_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
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 runner configuration. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [orchestration](#output\_orchestration) | Resources grouped under the selected runner orchestration provider. | +| [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | +| [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | +| [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. Null when webhook orchestration is not configured. | +| [scale\_up](#output\_scale\_up) | Scale-up control-plane resources. Null when webhook orchestration is not configured. | + diff --git a/modules/runner-config/common-config.tf b/modules/runner-config/common-config.tf new file mode 100644 index 0000000000..67b9cc1d07 --- /dev/null +++ b/modules/runner-config/common-config.tf @@ -0,0 +1,54 @@ +# Shared control-plane configuration: naming, paths, tags, and normalized values. +locals { + common_tags = var.tags + runner_tags = merge(local.common_tags, var.runner.tags) + lambda_tags = merge(local.common_tags, var.lambda.tags) + observability_log_tags = merge(local.common_tags, var.observability.logs.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 + packaged_runners_lambda_zip = "${path.module}/../../lambdas/functions/control-plane/runners.zip" + ssm_housekeeper_artifact_s3_selected = ( + var.ssm.housekeeper.lambda.artifact.s3 != null + ) + ssm_housekeeper_artifact = { + zip = local.ssm_housekeeper_artifact_s3_selected ? null : coalesce( + var.ssm.housekeeper.lambda.artifact.zip, + local.packaged_runners_lambda_zip, + ) + s3 = { + bucket = local.ssm_housekeeper_artifact_s3_selected ? var.lambda.artifact.s3.bucket : null + key = try(var.ssm.housekeeper.lambda.artifact.s3.key, null) + object_version = try(var.ssm.housekeeper.lambda.artifact.s3.object_version, null) + } + } + kms_key_id = var.ssm.kms_key_id + token_path = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" + 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 = "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 local.ssm_parameter_tags : { + Key = key + Value = value + } + ]) +} + +data "aws_caller_identity" "current" { + lifecycle { + precondition { + condition = ( + var.ssm.housekeeper.lambda.artifact.s3 == null || + var.lambda.artifact.s3.bucket != null + ) + error_message = "lambda.artifact.s3.bucket must be set when ssm.housekeeper.lambda.artifact.s3 is selected." + } + } +} diff --git a/modules/runner-stack/compute-provider.tf b/modules/runner-config/compute-provider.tf similarity index 100% rename from modules/runner-stack/compute-provider.tf rename to modules/runner-config/compute-provider.tf diff --git a/modules/runner-stack/ec2.tf b/modules/runner-config/ec2.tf similarity index 100% rename from modules/runner-stack/ec2.tf rename to modules/runner-config/ec2.tf diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf new file mode 100644 index 0000000000..9084b3a9dd --- /dev/null +++ b/modules/runner-config/orchestration-provider.tf @@ -0,0 +1,87 @@ +locals { + orchestration_providers = { + for provider_type, provider_config in var.orchestration : provider_type => provider_config + if provider_config != null + } + + orchestration_provider_type = one(keys(local.orchestration_providers)) + + orchestration_provider_enabled = { + webhook = local.orchestration_provider_type == "webhook" + } +} + +moved { + from = module.scale_runners + to = module.webhook["webhook"].module.scale_runners +} + +moved { + from = module.pool + to = module.webhook["webhook"].module.pool +} + +moved { + from = module.job_retry + to = module.webhook["webhook"].module.job_retry +} + +module "webhook" { + source = "../orchestration-providers/webhook" + for_each = { + for provider_type, provider_config in local.orchestration_providers : provider_type => provider_config + if provider_type == "webhook" + } + + aws_partition = var.aws_partition + prefix = var.prefix + tags = var.tags + + config = each.value + runner = var.runner + github = var.github + lambda = { + artifact = var.lambda.artifact + 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 = local.lambda_role_path + permissions_boundary = var.lambda.role.permissions_boundary + principals = var.lambda.principals + } + } + ssm = { + token_path = local.token_path + token_path_arn = local.arn_ssm_parameters_path_tokens + config_path = "${var.ssm.paths.root}/${var.ssm.paths.config}" + config_path_arn = local.arn_ssm_parameters_path_config + kms_key_id = local.kms_key_id + parameter_store_tags = local.parameter_store_tags + } + observability = var.observability + + runner_provider = { + type = local.provider_type + scale_up = { + environment_variables = local.provider_contract.environment_variables.scale_up + iam_policy_json = local.provider_contract.policies.scale_up.iam_policy_json + additional_iam_policy_json = local.provider_contract.policies.scale_up.additional_iam_policy_json + managed_policy = local.provider_contract.policies.scale_up.managed_policy_enabled ? { + arn = local.provider_contract.policies.scale_up.managed_policy_arn + } : null + } + scale_down = { + environment_variables = local.provider_contract.environment_variables.scale_down + iam_policy_json = local.provider_contract.policies.scale_down.iam_policy_json + } + pool = { + environment_variables = local.provider_contract.environment_variables.pool + iam_policy_json = local.provider_contract.policies.pool.iam_policy_json + managed_policy_enabled = local.provider_contract.policies.pool.managed_policy_enabled + managed_policy_arn = local.provider_contract.policies.pool.managed_policy_arn + } + } +} diff --git a/modules/runner-config/outputs.tf b/modules/runner-config/outputs.tf new file mode 100644 index 0000000000..6a2ec80e86 --- /dev/null +++ b/modules/runner-config/outputs.tf @@ -0,0 +1,40 @@ +output "runner" { + description = "Common runner resources. The role is null when an external runner role is used." + value = { + role = one(aws_iam_role.runner[*]) + } +} + +output "scale_up" { + description = "Scale-up control-plane resources. Null when webhook orchestration is not configured." + value = one([for provider in values(module.webhook) : provider.scale_up]) +} + +output "scale_down" { + description = "Scale-down control-plane resources. Null when webhook orchestration is not configured." + value = one([for provider in values(module.webhook) : provider.scale_down]) +} + +output "pool" { + description = "Scheduled pool resources. Null when no pool configuration is supplied." + value = one([for provider in values(module.webhook) : provider.pool]) +} + +output "orchestration" { + description = "Resources grouped under the selected runner orchestration provider." + value = { + webhook = local.orchestration_provider_enabled.webhook ? { + scale_up = one([for provider in values(module.webhook) : provider.scale_up]) + scale_down = one([for provider in values(module.webhook) : provider.scale_down]) + pool = one([for provider in values(module.webhook) : provider.pool]) + job_retry = one([for provider in values(module.webhook) : provider.job_retry]) + } : null + } +} + +output "provider" { + description = "Provider-specific resources grouped under the selected provider key." + value = { + (local.provider_type) = local.provider_contract.resources + } +} diff --git a/modules/runner-stack/runner-role.tf b/modules/runner-config/runner-role.tf similarity index 95% rename from modules/runner-stack/runner-role.tf rename to modules/runner-config/runner-role.tf index 0422487d7a..6baa1e4206 100644 --- a/modules/runner-stack/runner-role.tf +++ b/modules/runner-config/runner-role.tf @@ -1,5 +1,5 @@ locals { - # Role ownership belongs to the common stack. The selected trust-policy + # Role ownership belongs to the common runner configuration. The selected trust-policy # submodule supplies the assume-role document, while the full compute provider # supplies permissions after the role has been resolved. create_runner_role = var.runner.iam.role == null diff --git a/modules/runner-stack/runner-ssm-parameters.tf b/modules/runner-config/runner-ssm-parameters.tf similarity index 100% rename from modules/runner-stack/runner-ssm-parameters.tf rename to modules/runner-config/runner-ssm-parameters.tf diff --git a/modules/runner-stack/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper.tf similarity index 90% rename from modules/runner-stack/ssm-housekeeper.tf rename to modules/runner-config/ssm-housekeeper.tf index 0e4a78cd5d..6ed3aff418 100644 --- a/modules/runner-stack/ssm-housekeeper.tf +++ b/modules/runner-config/ssm-housekeeper.tf @@ -22,10 +22,9 @@ module "ssm_housekeeper" { dry_run = var.ssm.housekeeper.config.dryRun } lambda = { - artifact = { - zip = local.lambda_zip - s3 = var.lambda.s3 - } + # The housekeeper resolves only its component-owned selector and never + # inherits the selected orchestration provider's scale artifact. + artifact = local.ssm_housekeeper_artifact runtime = var.lambda.runtime architecture = var.lambda.architecture memory_size = var.ssm.housekeeper.lambda.memory_size diff --git a/modules/runner-config/ssm-housekeeper/README.md b/modules/runner-config/ssm-housekeeper/README.md new file mode 100644 index 0000000000..dfe3ff3378 --- /dev/null +++ b/modules/runner-config/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 configuration. It is composed by `runner-config` 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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda 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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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-config/ssm-housekeeper/iam-policies.tf similarity index 94% rename from modules/runner-stack/ssm-housekeeper/iam-policies.tf rename to modules/runner-config/ssm-housekeeper/iam-policies.tf index e093301eac..8d3bab2865 100644 --- a/modules/runner-stack/ssm-housekeeper/iam-policies.tf +++ b/modules/runner-config/ssm-housekeeper/iam-policies.tf @@ -21,6 +21,7 @@ data "aws_iam_policy_document" "lambda_assume_role" { data "aws_iam_policy_document" "lambda_xray" { count = var.config.observability.tracing.mode != null ? 1 : 0 + # AWS X-Ray trace APIs do not support resource-level permissions. statement { sid = "AllowXRay" effect = "Allow" diff --git a/modules/runner-stack/ssm-housekeeper/outputs.tf b/modules/runner-config/ssm-housekeeper/outputs.tf similarity index 100% rename from modules/runner-stack/ssm-housekeeper/outputs.tf rename to modules/runner-config/ssm-housekeeper/outputs.tf diff --git a/modules/runner-stack/ssm-housekeeper/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper/ssm-housekeeper.tf similarity index 100% rename from modules/runner-stack/ssm-housekeeper/ssm-housekeeper.tf rename to modules/runner-config/ssm-housekeeper/ssm-housekeeper.tf diff --git a/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl b/modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl similarity index 93% rename from modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl rename to modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl index 134417cecc..bac30c6752 100644 --- a/modules/runner-stack/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl +++ b/modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl @@ -62,7 +62,7 @@ variables { security_group_ids = [] } role = { - path = "/runner-stack/" + path = "/runner-config/" permissions_boundary = null principals = [{ type = "AWS" @@ -202,7 +202,7 @@ run "enables_vpc_and_xray_together" { security_group_ids = ["sg-12345678"] } role = { - path = "/runner-stack/" + path = "/runner-config/" permissions_boundary = null } } @@ -249,4 +249,15 @@ run "enables_vpc_and_xray_together" { ) error_message = "Active tracing must configure Lambda tracing, X-Ray IAM permissions, and tracing-helper environment variables." } + + assert { + condition = ( + data.aws_iam_policy_document.lambda_xray[0].statement[0].resources == toset(["*"]) + && alltrue([ + for action in data.aws_iam_policy_document.lambda_xray[0].statement[0].actions : + startswith(action, "xray:") + ]) + ) + error_message = "The housekeeper wildcard resource must be limited to X-Ray APIs, which do not support resource-level IAM permissions." + } } diff --git a/modules/runner-stack/ssm-housekeeper/variables.tf b/modules/runner-config/ssm-housekeeper/variables.tf similarity index 99% rename from modules/runner-stack/ssm-housekeeper/variables.tf rename to modules/runner-config/ssm-housekeeper/variables.tf index c54620369c..64848fc33c 100644 --- a/modules/runner-stack/ssm-housekeeper/variables.tf +++ b/modules/runner-config/ssm-housekeeper/variables.tf @@ -1,6 +1,6 @@ variable "config" { description = <<-EOT - Provider-neutral SSM housekeeper configuration assembled by runner-stack. + Provider-neutral SSM housekeeper configuration assembled by runner-config. - `prefix`: Prefix used to name the housekeeper resources. - `aws_partition`: AWS partition used to construct IAM policy ARNs. diff --git a/modules/runner-stack/versions.tf b/modules/runner-config/ssm-housekeeper/versions.tf similarity index 100% rename from modules/runner-stack/versions.tf rename to modules/runner-config/ssm-housekeeper/versions.tf diff --git a/modules/runner-stack/tests/README.md b/modules/runner-config/tests/README.md similarity index 100% rename from modules/runner-stack/tests/README.md rename to modules/runner-config/tests/README.md diff --git a/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl b/modules/runner-config/tests/computed-iam-inputs.tftest.hcl similarity index 68% rename from modules/runner-stack/tests/computed-iam-inputs.tftest.hcl rename to modules/runner-config/tests/computed-iam-inputs.tftest.hcl index 9e81f63b64..9fcea735ed 100644 --- a/modules/runner-stack/tests/computed-iam-inputs.tftest.hcl +++ b/modules/runner-config/tests/computed-iam-inputs.tftest.hcl @@ -13,6 +13,16 @@ run "computed_external_values_keep_plan_shape_known" { source = "./tests/fixtures/computed-iam-inputs" } + # The packaged runner archive is added by the release build, so the computed + # IAM fixture isolates the two common housekeeper children in a source checkout. + override_module { + target = module.external_iam.module.ssm_housekeeper + } + + override_module { + target = module.generated_policy.module.ssm_housekeeper + } + assert { condition = output.external_role_runner_count == 0 error_message = "Computed external AMI parameter, KMS key, role, and profile values must not make resource or policy-block counts unknown." diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md similarity index 90% rename from modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md rename to modules/runner-config/tests/fixtures/computed-iam-inputs/README.md index 08c02ba66d..7511cca2e0 100644 --- a/modules/runner-stack/tests/fixtures/computed-iam-inputs/README.md +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md @@ -2,27 +2,27 @@ ## 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 | @@ -33,7 +33,7 @@ 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 + diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf similarity index 68% rename from modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf rename to modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index a00a8ba7c8..37164791fc 100644 --- a/modules/runner-stack/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -50,34 +50,15 @@ module "external_iam" { } } - queue = { - build = { - arn = "arn:aws:sqs:eu-west-1:123456789012:computed-external" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-external" - } - } - lambda = { - s3 = { - bucket = "lambda-artifacts" - key = "runners.zip" + artifact = { + s3 = { + bucket = "lambda-artifacts" + } } } - job_retry = { - enabled = true - } - - pool = { - runner_owner = "example" - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - size = 1 - }] - } - github = { - organization_runners = true app_parameters = { key_base64 = [{ name = "/github-runner/key-base64" @@ -91,6 +72,43 @@ module "external_iam" { } } + orchestration = { + webhook = { + runner = { + maximum_count = 3 + } + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-external" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-external" + } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/build-queue-${random_id.external.hex}" + } + lambda = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + pool = { + runner_owner = "example" + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + } + job_retry = { + enabled = true + } + } + } + ssm = { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" paths = { @@ -130,22 +148,15 @@ module "generated_policy" { } } - queue = { - build = { - arn = "arn:aws:sqs:eu-west-1:123456789012:computed-policy" - url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-policy" - } - } - lambda = { - s3 = { - bucket = "lambda-artifacts" - key = "runners.zip" + artifact = { + s3 = { + bucket = "lambda-artifacts" + } } } github = { - organization_runners = true app_parameters = { key_base64 = [{ name = "/github-runner/key-base64" @@ -159,6 +170,32 @@ module "generated_policy" { } } + orchestration = { + webhook = { + runner = { + maximum_count = 3 + } + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-policy" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-policy" + } + } + lambda = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + } + } + } + ssm = { paths = { root = "/github-runner/computed-policy" diff --git a/modules/runner-stack/tests/fixtures/computed-iam-inputs/versions.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf similarity index 100% rename from modules/runner-stack/tests/fixtures/computed-iam-inputs/versions.tf rename to modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf diff --git a/modules/runner-stack/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl similarity index 53% rename from modules/runner-stack/tests/pool.tftest.hcl rename to modules/runner-config/tests/pool.tftest.hcl index 4230b222a2..23621e9d87 100644 --- a/modules/runner-stack/tests/pool.tftest.hcl +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -18,6 +18,12 @@ mock_provider "aws" { } } +# The runner archive is injected during packaging, so isolate the common +# housekeeper child in source-checkout tests where that build artifact is absent. +override_module { + target = module.ssm_housekeeper +} + variables { aws_region = "eu-west-1" @@ -63,23 +69,15 @@ variables { } } - 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" - key = "runners.zip" + artifact = { + s3 = { + bucket = "my-lambda-bucket" + } } } 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" }] @@ -87,6 +85,38 @@ variables { } } + orchestration = { + webhook = { + runner = { + maximum_count = 9 + } + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + } + lambda = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + } + } + } + ssm = { paths = { root = "/github-runner" @@ -95,36 +125,49 @@ variables { } } - # 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 + condition = module.webhook["webhook"].pool != null error_message = "Pool module should be enabled when pool.config is non-empty" } + assert { + condition = ( + !contains(keys(var.runner), "maximum_count") + && var.orchestration.webhook.runner.maximum_count == 9 + && module.webhook["webhook"].scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + && module.webhook["webhook"].pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + ) + error_message = "Runner capacity must be owned by orchestration.webhook.runner and routed to webhook scale-up and pool, not retained in the common runner contract." + } + + assert { + condition = ( + module.webhook["webhook"].scale_up.lambda.s3_bucket == "my-lambda-bucket" + && module.webhook["webhook"].scale_up.lambda.s3_key == "runners.zip" + && local.ssm_housekeeper_artifact.s3.bucket == null + && endswith(local.ssm_housekeeper_artifact.zip, "/lambdas/functions/control-plane/runners.zip") + ) + error_message = "The webhook provider must combine its artifact key with the shared bucket while the common SSM housekeeper remains on the packaged archive." + } + assert { condition = toset(keys(output.provider)) == toset(["ec2"]) - error_message = "The runner stack must expose resources only under the selected provider key." + error_message = "The runner configuration must expose resources only under the selected provider key." } assert { condition = contains(keys(output.provider.ec2), "launch_template") - error_message = "The runner stack must expose EC2 resources only under provider.ec2." + error_message = "The runner configuration must expose EC2 resources only under provider.ec2." } assert { condition = length(aws_iam_role.runner) == 1 && output.runner.role != null - error_message = "The common runner stack must create and expose the runner role." + error_message = "The common runner configuration must create and expose the runner role." } assert { @@ -144,7 +187,18 @@ run "plan_with_pool_enabled" { } assert { - condition = length(jsondecode(module.scale_runners.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 + condition = ( + toset(keys(output.orchestration)) == toset(["webhook"]) + && output.orchestration.webhook != null + && output.orchestration.webhook.scale_up != null + && output.orchestration.webhook.scale_down != null + && output.orchestration.webhook.pool != null + ) + error_message = "The canonical orchestration output must group the existing webhook control-plane resources while flat aliases remain available." + } + + assert { + condition = length(jsondecode(module.webhook["webhook"].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." } @@ -163,7 +217,7 @@ run "plan_with_pool_enabled" { "distribution_bucket", "cloudwatch", ]) - error_message = "The common stack must attach every enabled EC2 runner policy by its stable provider key." + error_message = "The common runner configuration must attach every enabled EC2 runner policy by its stable provider key." } assert { @@ -173,32 +227,167 @@ run "plan_with_pool_enabled" { assert { condition = ( - module.scale_runners.scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" - && module.scale_runners.scale_down.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + module.webhook["webhook"].scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + && module.webhook["webhook"].scale_down.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" ) error_message = "Scaling Lambdas must receive the provider type from the selected provider." } assert { - condition = module.scale_runners.scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + condition = module.webhook["webhook"].scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" error_message = "Scale-up must merge the EC2 environment fragment." } assert { - condition = module.scale_runners.scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" + condition = module.webhook["webhook"].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" error_message = "Scale-down must merge the EC2 environment fragment." } assert { 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"]) + toset(keys(module.webhook["webhook"].scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(module.webhook["webhook"].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." } } +run "housekeeper_uses_component_s3_artifact" { + command = plan + + variables { + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + s3 = { + key = "housekeeper/runner-config.zip" + object_version = "housekeeper-version" + } + } + } + } + } + } + + assert { + condition = ( + local.ssm_housekeeper_artifact.zip == null + && local.ssm_housekeeper_artifact.s3.bucket == "my-lambda-bucket" + && local.ssm_housekeeper_artifact.s3.key == "housekeeper/runner-config.zip" + && local.ssm_housekeeper_artifact.s3.object_version == "housekeeper-version" + ) + error_message = "The SSM housekeeper must combine its component-owned S3 key and version with the common Lambda artifact bucket." + } +} + +run "housekeeper_uses_component_local_zip" { + command = plan + + variables { + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } + } + } + + assert { + condition = ( + local.ssm_housekeeper_artifact.zip == "README.md" + && local.ssm_housekeeper_artifact.s3.bucket == null + && local.ssm_housekeeper_artifact.s3.key == null + && local.ssm_housekeeper_artifact.s3.object_version == null + ) + error_message = "The SSM housekeeper must use its component-owned local zip without inheriting the common bucket or webhook artifact." + } +} + +run "rejects_conflicting_housekeeper_artifacts" { + command = plan + + variables { + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + s3 = { + key = "housekeeper/runner-config.zip" + } + } + } + } + } + } + + expect_failures = [var.ssm] +} + +run "rejects_housekeeper_s3_without_common_bucket" { + command = plan + + variables { + lambda = { + artifact = { + s3 = { + bucket = null + } + } + } + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + s3 = { + key = "housekeeper/runner-config.zip" + } + } + } + } + } + } + + expect_failures = [data.aws_caller_identity.current] +} + +run "rejects_missing_orchestration_provider" { + command = plan + + variables { + orchestration = { + webhook = null + } + } + + expect_failures = [var.orchestration] +} + run "external_runner_role_is_not_managed_by_common" { command = plan @@ -215,7 +404,7 @@ run "external_runner_role_is_not_managed_by_common" { 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." + error_message = "An external runner role must remain unmanaged by the common runner configuration." } assert { @@ -259,7 +448,7 @@ run "external_runner_role_and_profile_remain_external" { 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." + error_message = "The common runner configuration must not manage an external role." } assert { @@ -355,21 +544,43 @@ run "job_retry_uses_common_runner_configuration_identity" { labels = ["self-hosted", "linux", "x64"] name_prefix = "provider-neutral-" } - job_retry = { - enabled = true - lambda = { - reserved_concurrent_executions = 2 + orchestration = { + webhook = { + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + } + lambda = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + } + job_retry = { + enabled = true + lambda = { + reserved_concurrent_executions = 2 + } + } } } } assert { - condition = module.job_retry[0].lambda.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" + condition = module.webhook["webhook"].job_retry.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.reserved_concurrent_executions == 2 + condition = module.webhook["webhook"].job_retry.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-config/tests/tags.tftest.hcl similarity index 67% rename from modules/runner-stack/tests/tags.tftest.hcl rename to modules/runner-config/tests/tags.tftest.hcl index 0f6f174d4b..f627751df8 100644 --- a/modules/runner-stack/tests/tags.tftest.hcl +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -18,6 +18,12 @@ mock_provider "aws" { } } +# The runner archive is injected during packaging, so model the common +# housekeeper output while testing parent-level tag composition from source. +override_module { + target = module.ssm_housekeeper +} + variables { aws_region = "eu-west-1" @@ -57,21 +63,11 @@ variables { } } - 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" + artifact = { + s3 = { + bucket = "my-lambda-bucket" + } } tags = { precedence = "lambda" @@ -80,7 +76,6 @@ variables { } github = { - organization_runners = true app_parameters = { key_base64 = [{ name = "/github-runner/key-base64" @@ -94,36 +89,59 @@ variables { } } - 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" + orchestration = { + webhook = { + github = { + organization_runners = true + } + 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 = { + scale = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + 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" + } + } } } @@ -166,22 +184,22 @@ run "layered_component_tags" { command = plan assert { - condition = module.scale_runners.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" + condition = module.webhook["webhook"].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 = module.scale_runners.scale_up.lambda.tags == tomap({ + condition = module.webhook["webhook"].scale_up.lambda.tags == tomap({ precedence = "scale-up" module = "yes" lambda = "yes" scale_up = "yes" - }) && module.scale_runners.scale_up.log_group.tags == tomap({ + }) && module.webhook["webhook"].scale_up.log_group.tags == tomap({ precedence = "scale-up" module = "yes" log = "yes" scale_up = "yes" - }) && module.scale_runners.scale_up.role.tags == tomap({ + }) && module.webhook["webhook"].scale_up.role.tags == tomap({ precedence = "scale-up" module = "yes" scale_up = "yes" @@ -190,17 +208,17 @@ run "layered_component_tags" { } assert { - condition = module.scale_runners.scale_down.lambda.tags == tomap({ + condition = module.webhook["webhook"].scale_down.lambda.tags == tomap({ precedence = "scale-down" module = "yes" lambda = "yes" scale_down = "yes" - }) && module.scale_runners.scale_down.log_group.tags == tomap({ + }) && module.webhook["webhook"].scale_down.log_group.tags == tomap({ precedence = "scale-down" module = "yes" log = "yes" scale_down = "yes" - }) && module.scale_runners.scale_down.role.tags == tomap({ + }) && module.webhook["webhook"].scale_down.role.tags == tomap({ precedence = "scale-down" module = "yes" scale_down = "yes" @@ -224,7 +242,7 @@ run "layered_component_tags" { ssm = "yes" parameter = "yes" }) && tomap({ - for tag in jsondecode(module.scale_runners.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + for tag in jsondecode(module.webhook["webhook"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ precedence = "ssm-parameter" @@ -236,19 +254,19 @@ run "layered_component_tags" { } assert { - condition = module.ssm_housekeeper.housekeeper.lambda.tags == tomap({ + condition = local.ssm_housekeeper_lambda_tags == tomap({ precedence = "ssm-housekeeper" module = "yes" lambda = "yes" ssm = "yes" housekeeper = "yes" - }) && module.ssm_housekeeper.housekeeper.log_group.tags == tomap({ + }) && local.ssm_housekeeper_log_tags == tomap({ precedence = "ssm-housekeeper" module = "yes" log = "yes" ssm = "yes" housekeeper = "yes" - }) && module.ssm_housekeeper.housekeeper.role.tags == tomap({ + }) && local.ssm_housekeeper_tags == tomap({ precedence = "ssm-housekeeper" module = "yes" ssm = "yes" @@ -258,17 +276,17 @@ run "layered_component_tags" { } assert { - condition = module.pool[0].pool.lambda.tags == tomap({ + condition = module.webhook["webhook"].pool.lambda.tags == tomap({ precedence = "pool" module = "yes" lambda = "yes" pool = "yes" - }) && module.pool[0].pool.log_group.tags == tomap({ + }) && module.webhook["webhook"].pool.log_group.tags == tomap({ precedence = "pool" module = "yes" log = "yes" pool = "yes" - }) && module.pool[0].pool.role.tags == tomap({ + }) && module.webhook["webhook"].pool.role.tags == tomap({ precedence = "pool" module = "yes" pool = "yes" @@ -277,21 +295,21 @@ run "layered_component_tags" { } assert { - condition = module.job_retry[0].lambda.function.tags == tomap({ + condition = module.webhook["webhook"].job_retry.lambda.function.tags == tomap({ precedence = "job-retry" module = "yes" lambda = "yes" job_retry = "yes" - }) && module.job_retry[0].lambda.log_group.tags == tomap({ + }) && module.webhook["webhook"].job_retry.lambda.log_group.tags == tomap({ precedence = "job-retry" module = "yes" log = "yes" job_retry = "yes" - }) && module.job_retry[0].lambda.role.tags == tomap({ + }) && module.webhook["webhook"].job_retry.lambda.role.tags == tomap({ precedence = "job-retry" module = "yes" job_retry = "yes" - }) && module.job_retry[0].job_retry_check_queue.tags == tomap({ + }) && module.webhook["webhook"].job_retry.queue.tags == tomap({ precedence = "job-retry" module = "yes" queue = "yes" diff --git a/modules/runner-stack/variables.compute-provider.tf b/modules/runner-config/variables.compute-provider.tf similarity index 99% rename from modules/runner-stack/variables.compute-provider.tf rename to modules/runner-config/variables.compute-provider.tf index f8cb67d9a2..a8306cad14 100644 --- a/modules/runner-stack/variables.compute-provider.tf +++ b/modules/runner-config/variables.compute-provider.tf @@ -20,7 +20,7 @@ variable "compute_provider" { - `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.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the runner-configuration 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. @@ -59,7 +59,7 @@ variable "compute_provider" { - `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[].prefix_log_group`: Prefixes the log-group name with the runner configuration 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. diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf new file mode 100644 index 0000000000..348377b331 --- /dev/null +++ b/modules/runner-config/variables.orchestration-provider.tf @@ -0,0 +1,121 @@ +# Typed orchestration-provider input boundary between the common runner configuration and demand controllers. +variable "orchestration" { + description = <<-EOT + Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. + + `webhook` is the currently supported provider. It owns the build queue reference, scale-control + artifact selection, runner capacity limit, scale-up, scale-down, scheduled pool, and job-retry + controls. Wrapper presence selects the provider and must therefore be known during planning. Future + providers can be added as sibling blocks without moving the webhook contract again. + EOT + type = object({ + webhook = optional(object({ + runner = optional(object({ + maximum_count = optional(number, 3) + }), {}) + github = object({ + organization_runners = bool + }) + queue = object({ + build = object({ + arn = string + url = string + }) + kms_key_id = optional(string, null) + tags = optional(map(string), {}) + }) + lambda = optional(object({ + scale = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + }), {}) + scale_up = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 60) + reserved_concurrent_executions = optional(number, 1) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, 10) + maximum_batching_window_in_seconds = optional(number, 0) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(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") + })), []) + }), {}) + pool = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 60) + reserved_concurrent_executions = optional(number, 1) + 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), {}) + }), {}) + }), {}) + 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) + }), {}) + }), {}) + }), null) + }) + nullable = false + + validation { + condition = length([ + for provider_name, provider_config in var.orchestration : provider_name + if provider_config != null + ]) == 1 + error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook." + } + + validation { + condition = var.orchestration.webhook == null ? true : ( + var.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size >= 1 && + var.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size <= 1000 && + var.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds >= 0 && + var.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds <= 300 + ) + error_message = "orchestration.webhook.lambda.scale_up.event_source_mapping batch size must be between 1 and 1000 and its batching window between 0 and 300 seconds." + } + + validation { + condition = var.orchestration.webhook == null ? true : !( + var.orchestration.webhook.lambda.scale.artifact.zip != null && + var.orchestration.webhook.lambda.scale.artifact.s3 != null + ) + error_message = "orchestration.webhook.lambda.scale.artifact must select at most one of zip or s3." + } + + validation { + condition = var.orchestration.webhook == null ? true : (!var.orchestration.webhook.job_retry.enabled || var.orchestration.webhook.job_retry.delay_in_seconds <= 900) + error_message = "orchestration.webhook.job_retry.delay_in_seconds cannot exceed the SQS maximum of 900 seconds." + } +} diff --git a/modules/runner-stack/variables.tf b/modules/runner-config/variables.tf similarity index 58% rename from modules/runner-stack/variables.tf rename to modules/runner-config/variables.tf index d03b9ee21d..1f82064979 100644 --- a/modules/runner-stack/variables.tf +++ b/modules/runner-config/variables.tf @@ -16,7 +16,7 @@ variable "prefix" { } variable "tags" { - 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." + description = "Base tags added to taggable resources created by this runner configuration. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes." type = map(string) default = {} } @@ -34,7 +34,6 @@ variable "runner" { - `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. @@ -57,7 +56,6 @@ variable "runner" { 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) @@ -115,7 +113,6 @@ variable "github" { - `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. - `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. - `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - - `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. @@ -126,7 +123,6 @@ variable "github" { id = list(map(string)) installation_id = list(object({ name = string, arn = string })) }) - organization_runners = bool enterprise_server = optional(object({ url = optional(string, null) ssl_verify = optional(bool, true) @@ -135,49 +131,13 @@ variable "github" { }) } -variable "queue" { - 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 - url = string - }) - event_source_mapping = optional(object({ - batch_size = optional(number, 10) - maximum_batching_window_in_seconds = optional(number, 0) - }), {}) - tags = optional(map(string), {}) - }) - - 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." - } - - validation { - 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 "lambda" { description = <<-EOT - Configuration shared by the control-plane Lambda functions. + Common Lambda substrate independent of the selected runner orchestration provider. - - `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`. + - `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact. + - `runtime`: Runtime used by the control-plane Lambda functions. + - `architecture`: Instruction-set architecture used by the 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. @@ -186,11 +146,10 @@ variable "lambda" { - `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. EOT type = object({ - zip = optional(string, null) - s3 = optional(object({ - bucket = optional(string, null) - key = optional(string, null) - object_version = optional(string, null) + artifact = optional(object({ + s3 = optional(object({ + bucket = optional(string, null) + }), {}) }), {}) runtime = optional(string, "nodejs24.x") architecture = optional(string, "arm64") @@ -214,136 +173,24 @@ variable "lambda" { } } -variable "scale_up" { - 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 = <<-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 - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), []) - }) - default = {} -} - -variable "pool" { - 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 - 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) - }), {}) - }) - default = {} -} - -variable "job_retry" { - 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) - timeout = optional(number, 30) - }), {}) - }) - default = {} - - validation { - condition = !var.job_retry.enabled || var.job_retry.delay_in_seconds <= 900 - error_message = "job_retry.delay_in_seconds cannot exceed the SQS maximum of 900 seconds." - } -} - variable "ssm" { description = <<-EOT Parameter Store paths, encryption, tag scopes, and housekeeper configuration. - - `paths.root`: Root Parameter Store path for this runner stack. + - `paths.root`: Root Parameter Store path for this runner configuration. - `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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; IAM policy shape remains static. It does not select encryption for runtime-created runner parameters. + - `kms_key_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters. - `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.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact. + - `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. + - `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket. + - `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive. + - `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. - `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. @@ -366,6 +213,13 @@ variable "ssm" { state = optional(string, "ENABLED") tags = optional(map(string), {}) lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) memory_size = optional(number, 512) timeout = optional(number, 60) }), {}) @@ -376,6 +230,14 @@ variable "ssm" { }), {}) }), {}) }) + + validation { + condition = !( + var.ssm.housekeeper.lambda.artifact.zip != null && + var.ssm.housekeeper.lambda.artifact.s3 != null + ) + error_message = "ssm.housekeeper.lambda.artifact must select at most one of zip or s3." + } } variable "observability" { diff --git a/modules/runner-config/versions.tf b/modules/runner-config/versions.tf new file mode 100644 index 0000000000..da9769f550 --- /dev/null +++ b/modules/runner-config/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/README.md b/modules/runner-stack/README.md deleted file mode 100644 index 92a1ddb42c..0000000000 --- a/modules/runner-stack/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# Runner stack module - -> This module is treated as an internal module; breaking changes do not trigger a major release bump. - -This internal module implements the experimental provider-neutral runner control plane selected by `experimental.multi_runner_config`. 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 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 remain nested under a typed provider block. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. `multi-runner` resolves experimental globals and lane overrides first, then its final forwarding adapter preserves the wrapped `{ ec2 = ... }` object expected by this module. Exactly one provider block must be non-null, and the stack derives its provider type from that block rather than from a separate discriminator. - -The EC2 block reaches runner-stack with `compute_provider.ec2.binaries_syncer = { enabled, s3 }`; the S3 object is null when synchronization is disabled. Binary discovery and this shape adaptation happen in `multi-runner`, not inside runner-stack. Before creating the common runner role, the stack calls the selected provider's isolated `trust-policy` module to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full compute-provider module, which receives the resolved runner role only after it is created. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common stack attaches each returned policy group to its runner, scale-up, scale-down, or pool role. Provider-specific outputs remain grouped under the matching provider key, such as `provider.ec2`. EC2 is the only implemented Terraform compute provider in this phase. - -## Tagging - -`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `scale_up`, `scale_down`, `pool`, `job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes. - -Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `scale_up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `scale_up.tags`. - -Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and lane `compute_provider.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. - -## 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 `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.33 | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | -| [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | 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_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 | -| [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 | - -## 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.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`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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), {})
principals = optional(list(object({
type = string
identifiers = list(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 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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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), {})
additional_trust_policy_json = optional(string, null)
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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; IAM policy shape remains static. It does not select encryption for runtime-created runner parameters.
- `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_id = optional(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 | -|------|-------------| -| [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | -| [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | -| [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/common-config.tf b/modules/runner-stack/common-config.tf deleted file mode 100644 index 125e738fc9..0000000000 --- a/modules/runner-stack/common-config.tf +++ /dev/null @@ -1,49 +0,0 @@ -# Shared control-plane configuration: naming, paths, tags, and normalized values. -locals { - 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_id = var.ssm.kms_key_id - 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 local.ssm_parameter_tags : { - Key = key - Value = value - } - ]) -} - -data "aws_caller_identity" "current" {} diff --git a/modules/runner-stack/job-retry.tf b/modules/runner-stack/job-retry.tf deleted file mode 100644 index 33cedda00c..0000000000 --- a/modules/runner-stack/job-retry.tf +++ /dev/null @@ -1,62 +0,0 @@ - -locals { - job_retry_enabled = var.job_retry.enabled -} - -module "job_retry" { - source = "./job-retry" - count = local.job_retry_enabled ? 1 : 0 - - 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 = var.lambda.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_id = local.kms_key_id - } - 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/outputs.tf b/modules/runner-stack/outputs.tf deleted file mode 100644 index b7b03822cb..0000000000 --- a/modules/runner-stack/outputs.tf +++ /dev/null @@ -1,28 +0,0 @@ -output "runner" { - description = "Common runner resources. The role is null when an external runner role is used." - value = { - role = one(aws_iam_role.runner[*]) - } -} - -output "scale_up" { - description = "Scale-up control-plane resources." - value = module.scale_runners.scale_up -} - -output "scale_down" { - description = "Scale-down control-plane resources." - value = module.scale_runners.scale_down -} - -output "pool" { - description = "Scheduled pool resources. Null when no pool configuration is supplied." - value = one(module.pool[*].pool) -} - -output "provider" { - description = "Provider-specific resources grouped under the selected provider key." - value = { - (local.provider_type) = local.provider_contract.resources - } -} diff --git a/modules/runner-stack/pool.tf b/modules/runner-stack/pool.tf deleted file mode 100644 index 4e414804e2..0000000000 --- a/modules/runner-stack/pool.tf +++ /dev/null @@ -1,65 +0,0 @@ -module "pool" { - count = length(var.pool.config) == 0 ? 0 : 1 - - source = "./pool" - - config = { - prefix = var.prefix - ghes = { - ssl_verify = var.github.enterprise_server.ssl_verify - url = var.github.enterprise_server.url - } - user_agent = var.github.user_agent - github_app_parameters = var.github.app_parameters - runners_maximum_count = var.runner.maximum_count - kms_key_id = local.kms_key_id - lambda = { - 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 - 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 - principals = var.lambda.principals - } - 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.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}" - 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 - } - - aws_partition = var.aws_partition - tracing_config = var.observability.tracing - runner_provider = { - type = local.provider_type - environment_variables = local.provider_contract.environment_variables.pool - iam_policy_json = local.provider_contract.policies.pool.iam_policy_json - managed_policy_enabled = local.provider_contract.policies.pool.managed_policy_enabled - managed_policy_arn = local.provider_contract.policies.pool.managed_policy_arn - } -} diff --git a/modules/runner-stack/pool/README.md b/modules/runner-stack/pool/README.md deleted file mode 100644 index 7ac9036acb..0000000000 --- a/modules/runner-stack/pool/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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.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 | - -## 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters. The ARN may be unknown until apply; IAM policy shape remains static.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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_id = optional(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 | -|------|-------------| -| [pool](#output\_pool) | Scheduled pool Lambda resources. | - diff --git a/modules/runner-stack/scale-runners.tf b/modules/runner-stack/scale-runners.tf deleted file mode 100644 index 3431274089..0000000000 --- a/modules/runner-stack/scale-runners.tf +++ /dev/null @@ -1,87 +0,0 @@ -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 - principals = var.lambda.principals - } - } - 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_id = local.kms_key_id - 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_contract.environment_variables.scale_up - iam_policy_json = local.provider_contract.policies.scale_up.iam_policy_json - additional_iam_policy_json = local.provider_contract.policies.scale_up.additional_iam_policy_json - managed_policy = local.provider_contract.policies.scale_up.managed_policy_enabled ? { - arn = local.provider_contract.policies.scale_up.managed_policy_arn - } : null - } - scale_down = { - environment_variables = local.provider_contract.environment_variables.scale_down - iam_policy_json = local.provider_contract.policies.scale_down.iam_policy_json - } - } -} diff --git a/modules/runner-stack/scale-runners/README.md b/modules/runner-stack/scale-runners/README.md deleted file mode 100644 index 7dc16a4509..0000000000 --- a/modules/runner-stack/scale-runners/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# 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.
- `lambda.role.principals`: Additional principals allowed to assume 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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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 = list(map(string))
id = list(map(string))
installation_id = list(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_id = 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
})
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/ssm-housekeeper/README.md b/modules/runner-stack/ssm-housekeeper/README.md deleted file mode 100644 index bb3082e27f..0000000000 --- a/modules/runner-stack/ssm-housekeeper/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# 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.
- `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda 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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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. | - From c7dd66ad69aad7bd1b211ad68f27cc5dac1b4634 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:25:00 +0000 Subject: [PATCH 23/49] docs: auto update terraform docs --- modules/compute-providers/ec2/README.md | 10 +++++----- .../compute-providers/ec2/trust-policy/README.md | 10 +++++----- modules/multi-runner/README.md | 16 ++++++++-------- .../fixtures/computed-runner-inputs/README.md | 10 +++++----- .../orchestration-providers/webhook/README.md | 8 ++++---- .../webhook/job-retry/README.md | 10 +++++----- .../webhook/pool/README.md | 10 +++++----- .../webhook/scale-runners/README.md | 10 +++++----- modules/runner-config/README.md | 12 ++++++------ modules/runner-config/ssm-housekeeper/README.md | 10 +++++----- .../tests/fixtures/computed-iam-inputs/README.md | 10 +++++----- 11 files changed, 58 insertions(+), 58 deletions(-) diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index d1c19cb963..52c27ce437 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 runner configuration selects ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | @@ -28,7 +28,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 | @@ -58,7 +58,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 by compute-provider resources and policy documents. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 | @@ -72,7 +72,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | | [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | diff --git a/modules/compute-providers/ec2/trust-policy/README.md b/modules/compute-providers/ec2/trust-policy/README.md index 47b85fd9e5..58d0f0e67d 100644 --- a/modules/compute-providers/ec2/trust-policy/README.md +++ b/modules/compute-providers/ec2/trust-policy/README.md @@ -6,14 +6,14 @@ This internal submodule builds the EC2 runner-role trust policy independently fr ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | ## Modules @@ -23,19 +23,19 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [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.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the default EC2 runner-role trust policy. | `string` | `null` | no | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [assume\_role\_policy](#output\_assume\_role\_policy) | EC2 runner-role trust policy with the optional additional trust policy merged into it. | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 4a8eaec8ff..d5141913cc 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,15 +167,15 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | ## 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 | @@ -187,7 +187,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 | @@ -200,7 +200,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -285,7 +285,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/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 4f972ded54..29cd470cc9 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,26 +2,26 @@ ## 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 | -| ---- | ------ | ------- | +|------|--------|---------| | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs @@ -31,7 +31,7 @@ No inputs. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | | [runner\_config\_keys](#output\_runner\_config\_keys) | n/a | \ No newline at end of file diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index 073efafd43..bb6c288567 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -10,7 +10,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | @@ -21,7 +21,7 @@ No providers. ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | | [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | @@ -33,7 +33,7 @@ No resources. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [config](#input\_config) | Resolved provider-owned values from orchestration.webhook, including the runner capacity limit used by scaling and pool controls. |
object({
runner = object({
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
scale = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(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 = optional(map(string), {})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | | [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | @@ -48,7 +48,7 @@ No resources. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry](#output\_job\_retry) | Job-retry resources. Null when job retry is disabled. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool schedule is configured. | | [scale\_down](#output\_scale\_down) | Scale-down control-plane resources. | diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md index dece345834..f73c1053d1 100644 --- a/modules/orchestration-providers/webhook/job-retry/README.md +++ b/modules/orchestration-providers/webhook/job-retry/README.md @@ -11,14 +11,14 @@ The module is an inner module used by the webhook orchestration provider when th ## 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-config.

- `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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `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)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
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_id = 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
})
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/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index 7b537f7167..da0acb7ebb 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/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 webhook orchestration provider 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = 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,6 +59,6 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index 9b8e7f9426..a83aa44407 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -10,14 +10,14 @@ The module is an implementation detail of the experimental runner configuration. ## 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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume 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`: Webhook-provider runner capacity limit for this runner configuration.
- `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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = 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
})
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-config/README.md b/modules/runner-config/README.md index 289a9dd1e4..a9ce345933 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -69,20 +69,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\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | | [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | @@ -91,7 +91,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 | @@ -104,7 +104,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.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 runner-configuration 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 configuration 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 | -| ---- | ----------- | +|------|-------------| | [orchestration](#output\_orchestration) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | diff --git a/modules/runner-config/ssm-housekeeper/README.md b/modules/runner-config/ssm-housekeeper/README.md index dfe3ff3378..5f5d1ad166 100644 --- a/modules/runner-config/ssm-housekeeper/README.md +++ b/modules/runner-config/ssm-housekeeper/README.md @@ -10,14 +10,14 @@ The module is an implementation detail of the experimental runner configuration. ## 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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda 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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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-config/tests/fixtures/computed-iam-inputs/README.md b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md index 7511cca2e0..3bbf0f9027 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md @@ -2,27 +2,27 @@ ## 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 | @@ -33,7 +33,7 @@ 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 | From 8bfb2ec962514a87f67a45506a6dd2d5d39c384b Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 18:32:11 +0200 Subject: [PATCH 24/49] refactor(multi-runner): group webhook-owned settings --- ...-runner-orchestration-provider-boundary.md | 16 +- docs/index.md | 6 +- .../internal/compute-provider-refactor.md | 68 ++-- modules/compute-providers/ec2/README.md | 14 +- .../compute-providers/ec2/control-plane.tf | 8 +- .../ec2/tests/provider.tftest.hcl | 7 +- modules/compute-providers/ec2/variables.tf | 12 +- modules/multi-runner/README.md | 28 +- .../config.experimental.translation.tf | 173 +++++----- modules/multi-runner/runners.tf | 36 +- .../fixtures/computed-runner-inputs/README.md | 12 +- .../fixtures/computed-runner-inputs/main.tf | 8 +- .../tests/provider-routing.tftest.hcl | 324 ++++++++++-------- .../multi-runner/validations.experimental.tf | 28 +- .../multi-runner/variables.experimental.tf | 221 ++++++------ modules/multi-runner/webhook.tf | 2 +- .../orchestration-providers/webhook/README.md | 15 +- .../orchestration-providers/webhook/main.tf | 32 +- .../webhook/outputs.tf | 8 + .../orchestration-providers/webhook/pool.tf | 1 + .../webhook/pool/README.md | 14 +- .../webhook/pool/pool.tf | 1 + .../webhook/pool/tests/provider.tftest.hcl | 4 +- .../webhook/pool/variables.tf | 2 + .../webhook/scale-down-state-diagram.md | 2 +- .../webhook/scale-runners/README.md | 14 +- .../webhook/scale-runners/scale-down.tf | 1 + .../tests/scale-runners.tftest.hcl | 4 +- .../webhook/scale-runners/variables.tf | 2 + .../webhook/tests/webhook.tftest.hcl | 75 ++-- .../webhook/variables.tf | 75 ++-- modules/runner-config/README.md | 28 +- .../runner-config/orchestration-provider.tf | 4 + .../runner-config/runner-ssm-parameters.tf | 4 +- modules/runner-config/ssm-housekeeper.tf | 2 +- .../fixtures/computed-iam-inputs/README.md | 10 +- .../computed-iam-inputs.tf | 16 +- modules/runner-config/tests/pool.tftest.hcl | 43 ++- modules/runner-config/tests/tags.tftest.hcl | 28 +- .../variables.orchestration-provider.tf | 83 ++--- modules/runner-config/variables.tf | 6 - 41 files changed, 779 insertions(+), 658 deletions(-) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index dbf6310827..32742d30e5 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -48,7 +48,10 @@ experimental = { orchestration = { webhook = { runner = { - maximum_count = 4 + boot_time_in_minutes = 5 + ephemeral = true + jit_config_enabled = null + maximum_count = 4 } github = { @@ -81,23 +84,26 @@ Validation counts non-null provider blocks rather than naming one special case. The webhook global namespace owns: +- the default runner boot timeout used by webhook scale-down and pool components; +- the default ephemeral and just-in-time registration lifecycle used by webhook controls and runner bootstrap; - the default maximum runner count enforced by webhook scale-up and pool components; +- the repository allow list enforced by the shared webhook; - queue-selection strategy, EventBridge routing, and matcher-parameter tier; - build-queue defaults, redrive behavior, tags, and encryption; - the shared webhook Lambda configuration and artifact selection; -- the scale-control artifact selection; and +- the runner-control artifact shared by scale, pool, and job-retry; and - default scale-up, scale-down, and pool component settings. Job-retry remains a per-runner-configuration webhook setting in this phase; its typed block supplies its own defaults rather than inheriting a global block. -The maximum runner count is likewise provider-owned rather than common runner identity. Its canonical paths are `experimental.orchestration.webhook.runner.maximum_count` for the global default and `experimental.multi_runner_config..orchestration.webhook.runner.maximum_count` for a runner-configuration override. Stable-v1 translation maps the existing required `runners_maximum_count` into the latter path, and the unchanged stable `modules/runners` call reads it from that canonical provider block. No `runner.maximum_count` compatibility alias is retained in the experimental object. +Runner boot time, ephemeral mode, JIT configuration, and maximum runner count are webhook-provider settings rather than common runner identity. Their canonical paths live under `experimental.orchestration.webhook.runner`, with matching paths under `experimental.multi_runner_config..orchestration.webhook.runner` for runner-configuration overrides. Stable-v1 translation maps the existing lifecycle, boot-time, and capacity inputs into those provider paths, and the unchanged stable `modules/runners` call reads from the canonical provider block. No compatibility aliases are retained under the experimental common `runner` object. The webhook provider resolves a null JIT setting to the effective ephemeral mode, exposes that lifecycle contract to runner-config bootstrap, injects boot time into scale-down and pool, and keeps these settings out of compute-provider capabilities. -The common `experimental.github` block continues to own GitHub API client settings shared across implementations, including `enterprise_server` and `user_agent`. Webhook-specific GitHub settings are limited to fields such as `organization_runners`. +The common `experimental.github` block continues to own credentials and GitHub API client settings shared across implementations, including `enterprise_server` and `user_agent`. Repository filtering belongs to the shared webhook at `experimental.orchestration.webhook.github.repository_white_list`; per-configuration `organization_runners` remains in the same provider-owned GitHub block. The common `experimental.lambda` block contains only provider-neutral Lambda substrate: runtime, architecture, networking, role settings, additional principals, tags, and an optional shared artifact bucket. It does not select a provider archive. Each component owner supplies its own local zip or S3 object key and version. -The provider-neutral SSM housekeeper owns its artifact selector under `ssm.housekeeper.lambda.artifact`. A runner-configuration selection overrides the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the common Lambda artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. This selector is independent of the webhook scale artifact. Stable-v1 translation maps the existing runner artifact into both canonical component contracts so the translated representation remains complete without changing the stable resource path. +The webhook provider owns one runner-control artifact at `orchestration.webhook.lambda.artifact`, shared by scale, pool, and job-retry. Its `lambda.scale` child contains only `up` and `down` configuration, while the ingress webhook retains its separate `lambda.webhook.artifact`. The provider-neutral SSM housekeeper owns its artifact selector under `ssm.housekeeper.lambda.artifact`. A runner-configuration selection overrides the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the common Lambda artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. This selector is independent of the webhook runner-control artifact. Stable-v1 translation maps the existing runner artifact into both canonical component contracts so the translated representation remains complete without changing the stable resource path. For a selected webhook provider, resolution follows: diff --git a/docs/index.md b/docs/index.md index 00d14dc12c..7020b5f606 100644 --- a/docs/index.md +++ b/docs/index.md @@ -107,11 +107,11 @@ Multi-runner centralizes mode selection and canonical configuration in `config.e The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider`. Root `experimental.lambda` is provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults used across orchestration and non-orchestration consumers. Webhook-specific global defaults live together under `experimental.orchestration.webhook`, including the maximum runner count, the shared webhook's routing and matcher storage, build-queue defaults and encryption, control-plane artifact selectors, and webhook, scale-up, scale-down, and pool Lambda settings. This global block supplies defaults; it does not select an orchestration provider. Each runner configuration separately makes that selection through its own `orchestration` wrapper. The only supported orchestration provider today is `orchestration.webhook`, which owns that runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up and scale-down settings, scheduled pool, and job retry. Keeping those fields behind a typed provider wrapper allows future orchestration providers to be introduced as mutually exclusive siblings without moving the common runner, Lambda substrate, SSM, observability, or compute-provider contracts again. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides remain configuration-specific. A nullable runner-configuration field with a corresponding experimental global inherits that global value when omitted or null. A runner configuration that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. -Global `experimental.orchestration.webhook.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Runner-configuration `experimental.multi_runner_config[].orchestration.webhook.queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue, and its CMK is independent from `experimental.ssm.kms_key_id`. Runner-config forwards the distinct build-queue key to the webhook orchestration provider: scale-up receives `kms:Decrypt`, while job-retry receives `kms:Decrypt` and `kms:GenerateDataKey` for publishing. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when that publisher targets customer-managed encrypted queues. For v2, `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. +Global `experimental.orchestration.webhook.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Runner-configuration `experimental.multi_runner_config[].orchestration.webhook.queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue, and its CMK is independent from `experimental.ssm.kms_key_id`. Runner-config forwards the distinct build-queue key to the webhook orchestration provider: scale-up receives `kms:Decrypt`, while job-retry receives `kms:Decrypt` and `kms:GenerateDataKey` for publishing. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when that publisher targets customer-managed encrypted queues. For v2, `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner configurations consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-config GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. Both settings belong inside `experimental.github`; they are not root `experimental` fields or per-configuration orchestration settings. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner configurations consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-config GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. Both client settings belong inside `experimental.github`; they are not root `experimental` fields or per-configuration orchestration settings. -The shared webhook, runner configurations, SSM housekeepers, runner-binary syncer, termination watcher, and AMI housekeeper consume the provider-neutral runtime, architecture, networking, role, and tag defaults under `experimental.lambda`; `lambda.principals` configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global observability settings configure logging and tracing, and global metrics also configure the termination watcher. Webhook-orchestration control-plane artifact selection is global under `experimental.orchestration.webhook.lambda.scale.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. `experimental.orchestration.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `experimental.orchestration.webhook.lambda.webhook` owns the webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. +The shared webhook, runner configurations, SSM housekeepers, runner-binary syncer, termination watcher, and AMI housekeeper consume the provider-neutral runtime, architecture, networking, role, and tag defaults under `experimental.lambda`; `lambda.principals` configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global observability settings configure logging and tracing, and global metrics also configure the termination watcher. The runner-control artifact shared by webhook scale, pool, and job-retry is selected globally under `experimental.orchestration.webhook.lambda.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. `experimental.orchestration.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `experimental.orchestration.webhook.lambda.webhook` owns the separate ingress webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. `experimental.compute_provider.ec2.runner_binaries.enabled` defaults each EC2 runner configuration to the shared synchronized distribution, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. diff --git a/docs/modules/internal/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md index 2661c5b172..b93015541d 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -131,7 +131,6 @@ module "multi_runner" { runner = { os = "linux" architecture = "arm64" - ephemeral = true } github = { @@ -139,9 +138,6 @@ module "multi_runner" { # and every v2 runner configuration. app = var.github_app additional_apps = var.additional_github_apps - repository_white_list = [ - "example/example-repository", - ] # The URL also configures the shared termination watcher. TLS verification # and the User-Agent remain runner-config GitHub-client settings. @@ -172,7 +168,16 @@ module "multi_runner" { orchestration = { webhook = { runner = { - maximum_count = 4 + boot_time_in_minutes = 5 + ephemeral = true + jit_config_enabled = null + maximum_count = 4 + } + + github = { + repository_white_list = [ + "example/example-repository", + ] } queue_selection_strategy = "first" @@ -183,15 +188,13 @@ module "multi_runner" { matcher_config_parameter_store_tier = "Standard" lambda = { - scale = { - artifact = { - # Use zip instead for a local archive. Leave both fields null for - # the packaged runner archive. - zip = null - s3 = { - key = "runner-config.zip" - object_version = null - } + artifact = { + # Use zip instead for a local archive. Leave both fields null for + # the packaged runner archive shared by scale, pool, and job retry. + zip = null + s3 = { + key = "runner-config.zip" + object_version = null } } @@ -216,15 +219,16 @@ module "multi_runner" { } } - scale_up = { - memory_size = 1024 - event_source_mapping = { - batch_size = 5 + scale = { + up = { + memory_size = 1024 + event_source_mapping = { + batch_size = 5 + } + } + down = { + memory_size = 512 } - } - - scale_down = { - memory_size = 512 } pool = { @@ -404,15 +408,19 @@ module "multi_runner" { webhook = { # This runner configuration overrides the webhook provider's global cap. runner = { - maximum_count = 8 + boot_time_in_minutes = 7 + ephemeral = true + maximum_count = 8 } github = { organization_runners = true } lambda = { - scale_up = { - memory_size = 1536 + scale = { + up = { + memory_size = 1536 + } } } matcherConfig = { @@ -458,15 +466,15 @@ module "multi_runner" { ## Inputs, tags, and outputs -The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-configuration map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configurations and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-configuration SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration.webhook`: the maximum runner count, shared routing and matcher storage, queue defaults and encryption, runner-config scale artifact selection, and the webhook, scale-up, scale-down, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner configuration's separate `orchestration` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. +The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-configuration map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configurations and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-configuration SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration.webhook`: runner lifecycle, boot time, and maximum count; repository filtering; shared routing and matcher storage; queue defaults and encryption; the runner-control artifact shared by scale, pool, and job-retry; and the ingress webhook, scale, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner configuration's separate `orchestration` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. -Each v2 runner configuration groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration` provider wrapper. Its sole supported block today is `orchestration.webhook`, containing `runner.maximum_count`, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale_up`, `lambda.scale_down`, `lambda.pool`, and `job_retry`. A nullable per-configuration field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner configuration is therefore a non-null configuration override followed by the global nested value, including that field's nested schema default. Per-configuration precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. +Each v2 runner configuration groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration` provider wrapper. Its sole supported block today is `orchestration.webhook`, containing provider-owned runner lifecycle, boot-time, and capacity settings, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale.up`, `lambda.scale.down`, `lambda.pool`, and `job_retry`. A nullable per-configuration field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner configuration is therefore a non-null configuration override followed by the global nested value, including that field's nested schema default. Per-configuration precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. -Global `experimental.orchestration.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-configuration redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration.webhook.queue` override those global defaults, and runner-configuration queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. +Global `experimental.orchestration.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-configuration redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration.webhook.queue` override those global defaults, and runner-configuration queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. Queue encryption is global-only. Omitting the entire `experimental.orchestration.webhook.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue. Runner configurations cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent and are forwarded separately to `orchestration-providers/webhook`: scale-up receives queue-key `kms:Decrypt`, job-retry receives queue-key `kms:Decrypt` and `kms:GenerateDataKey`, and both retain separate Parameter Store decrypt statements. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when it publishes to customer-managed encrypted queues. The v1 translation retains the flat contract: per-configuration delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. -Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner configurations: `app` is required and `additional_apps` defaults to `[]`. `github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-config client settings. Neither field is a root `experimental` sibling. Per-configuration `orchestration.webhook.github.organization_runners` is the separate registration-scope setting; runner configurations do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. +Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner configurations: `app` is required and `additional_apps` defaults to `[]`. `experimental.orchestration.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-config client settings. Neither field is a root `experimental` sibling. Per-configuration `orchestration.webhook.github.organization_runners` is the separate registration-scope setting; runner configurations do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner configuration consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. @@ -480,7 +488,7 @@ The global `experimental.compute_provider` block owns v2 defaults for EC2 settin `experimental.compute_provider.ec2.runner_binaries` owns whether EC2 runner configurations use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. -Webhook-orchestration runner-config artifacts are selected globally through `experimental.orchestration.webhook.lambda.scale.artifact.zip` or `experimental.orchestration.webhook.lambda.scale.artifact.s3.{key,object_version}`. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The webhook artifact remains separate under `experimental.orchestration.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. +Webhook-orchestration runner-control artifacts are selected globally through `experimental.orchestration.webhook.lambda.artifact.zip` or `experimental.orchestration.webhook.lambda.artifact.s3.{key,object_version}` and shared by scale, pool, and job-retry. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The ingress webhook artifact remains separate under `experimental.orchestration.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-configuration tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes: shared SSM merges `experimental.tags` with `ssm.tags`, the webhook merges `experimental.lambda.tags` with `experimental.orchestration.webhook.lambda.webhook.tags`, and the runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags` and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-configuration `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index 52c27ce437..c8a09180b9 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. The parent runner configuration selects ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | | [terraform](#provider\_terraform) | n/a | ## Modules @@ -28,7 +28,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 | @@ -58,21 +58,21 @@ 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 by compute-provider resources and policy documents. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 available to compute-provider 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 available to compute-provider runner log groups.

- `logs.retention_in_days`: Retention period for provider-owned 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 identify resources created for the runner configuration. | `string` | `"github-actions"` | no | -| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-config manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
- `iam.path`: IAM path available to provider-managed IAM resources. 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
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | +| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture.
- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-config manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
- `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | | [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `paths.root`: Root Parameter Store path for the runner configuration.
- `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 available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | | [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | diff --git a/modules/compute-providers/ec2/control-plane.tf b/modules/compute-providers/ec2/control-plane.tf index d59a44f8bc..d476f91cb4 100644 --- a/modules/compute-providers/ec2/control-plane.tf +++ b/modules/compute-providers/ec2/control-plane.tf @@ -205,13 +205,9 @@ locals { USE_DEDICATED_HOST = var.config.use_dedicated_host } - scale_down_environment_variables = { - RUNNER_BOOT_TIME_IN_MINUTES = var.runner.boot_time_in_minutes - } + scale_down_environment_variables = {} - pool_environment_variables = merge(local.scale_up_environment_variables, { - RUNNER_BOOT_TIME_IN_MINUTES = var.runner.boot_time_in_minutes - }) + pool_environment_variables = local.scale_up_environment_variables scale_up_iam_policy_json = data.aws_iam_policy_document.scale_up.json scale_down_iam_policy_json = data.aws_iam_policy_document.scale_down.json diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/ec2/tests/provider.tftest.hcl index 9e733d2e72..bc92537279 100644 --- a/modules/compute-providers/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/ec2/tests/provider.tftest.hcl @@ -101,8 +101,11 @@ run "separates_control_plane_contract_from_ec2_resources" { } assert { - 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." + condition = ( + length(output.provider.environment_variables.scale_down) == 0 + && !contains(keys(output.provider.environment_variables.pool), "RUNNER_BOOT_TIME_IN_MINUTES") + ) + error_message = "The EC2 provider must not expose webhook-owned runner boot-time configuration." } assert { diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/ec2/variables.tf index 0826bd3f0b..c686049d79 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/ec2/variables.tf @@ -268,7 +268,6 @@ variable "runner" { - `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. @@ -281,12 +280,11 @@ variable "runner" { - `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. EOT type = 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") + os = optional(string, "linux") + architecture = optional(string, "x64") + name_prefix = optional(string, "") + run_as_root = optional(bool, false) + run_as = optional(string, "ec2-user") hooks = optional(object({ job_started = optional(string, "") job_completed = optional(string, "") diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index d5141913cc..c2cebe0379 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -27,11 +27,11 @@ The multi-runner module owns provider-neutral runner-configuration normalization A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-config` at `module.runner_configs["configuration"]`; a module-level moved block maps the former `module.runner_stacks` address to this composition name without recreating existing v2 resources. Sibling `experimental.tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each runner configuration can override supported configuration-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides affect only that runner configuration, never a singleton shared component. A nullable configuration field inherits its global value. Within v2 queue and runner-config scopes, tags merge from global to configuration and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. -The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, configuration-over-global precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, provider-owned scale artifacts, Lambda principals and shared artifact bucket, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references under `orchestration.webhook`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. +The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, configuration-over-global precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, the webhook-owned runner-control artifact, Lambda principals and shared artifact bucket, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references under `orchestration.webhook`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner configurations; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner configurations; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. -Shared singleton resources use the translated global contract without accepting per-configuration overrides. The shared webhook and its webhook-secret parameter retain their historical singleton addresses and are always created; only build-queue and matcher membership is filtered to runner configurations that select `orchestration.webhook`, so future non-webhook providers do not change the singleton lifecycle. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Global `experimental.lambda` contains only that shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults. Workflow-job webhook control-plane defaults live under `experimental.orchestration.webhook`. Runner-config control-plane artifacts come from `experimental.orchestration.webhook.lambda.scale.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Each runner configuration's SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the same shared artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation maps the legacy runner artifact into both canonical scale and SSM-housekeeper component contracts while preserving S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. `experimental.orchestration.webhook` owns queue selection, EventBridge routing, accepted event types, matcher-parameter tier, queue defaults, and webhook/scale/pool Lambda component defaults. Its `lambda.webhook` block owns webhook artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. +Shared singleton resources use the translated global contract without accepting per-configuration overrides. The shared webhook and its webhook-secret parameter retain their historical singleton addresses and are always created; only build-queue and matcher membership is filtered to runner configurations that select `orchestration.webhook`, so future non-webhook providers do not change the singleton lifecycle. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Global `experimental.lambda` contains only that shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults. Workflow-job webhook control-plane defaults live under `experimental.orchestration.webhook`. The runner-control artifact shared by scale, pool, and job-retry comes from `experimental.orchestration.webhook.lambda.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Each runner configuration's SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the same shared artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation maps the legacy runner artifact into both canonical runner-control and SSM-housekeeper component contracts while preserving S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the ingress webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. `experimental.orchestration.webhook` owns runner lifecycle, repository filtering, queue selection, EventBridge routing, accepted event types, matcher-parameter tier, queue defaults, and webhook/scale/pool Lambda component defaults. Its `lambda.webhook` block owns the ingress webhook's separate artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for configuration-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the configuration key only to runner-configuration roots. The default derived base is `/github-action-runners/${prefix}`, and runner-configuration token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration. It does not select encryption for runtime-created runner-configuration parameters. Provider-owned runner-config IAM omits the KMS statement when the key is null and still accepts an ARN whose value is unknown until apply; the unchanged shared webhook retains its legacy policy handling. @@ -43,13 +43,13 @@ Phase 1 supports both input contracts with deterministic precedence. When `exper Global `experimental.orchestration.webhook.queue` owns the v2 defaults for build-queue delay, retention, visibility, redrive, tags, and encryption. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. Redrive defaults to disabled with a null `maxReceiveCount`; a null configuration wrapper or leaf inherits the matching global value, and an enabled result requires `maxReceiveCount` greater than zero. Configuration fields under `multi_runner_config[].orchestration.webhook.queue` override the corresponding global queue defaults, and configuration tags merge over global queue tags. Encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. When supplying the block explicitly, provide all three leaves and use null for inactive KMS or SQS-managed settings. A non-null `kms_master_key_id` must be a KMS key ARN—not a key ID or alias—because it also becomes an IAM resource; a computed ARN may remain unknown until apply. This block configures the multi-runner build queues and their dead-letter queues, not runner-config job-retry queues. Its CMK is independent from `experimental.ssm.kms_key_id` and is forwarded separately to each webhook runner configuration: provider-owned IAM grants scale-up `kms:Decrypt` and job-retry `kms:Decrypt` plus `kms:GenerateDataKey`. The shared webhook module is intentionally unchanged, so its producer role does not derive queue-CMK permissions from this field; ensure the key policy or caller-managed IAM covers producer access when required. -For v2, `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. +For v2, `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. Global `experimental.compute_provider.ec2.runner_binaries` owns the shared distribution-bucket and syncer defaults. It covers runner-configuration enablement, bucket encryption, tags, versioning and access logging, plus the syncer artifact, Lambda sizing, and schedule. `runner_binaries.enabled` defaults to `true`, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides that default; enabled distributions are created once per unique operating-system and architecture pair. The resolved enable value, `s3.encryption.enabled`, the nullness of `s3.encryption.kms_master_key_id`, and the nullness of `s3.logging.bucket` must be known during planning because they determine module or resource shape. A distribution CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from it; attach that permission separately when using a CMK. ### V2 tagging -For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `orchestration.webhook.queue.tags`, and configuration-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `orchestration.webhook.lambda.scale_up.tags`, `orchestration.webhook.lambda.scale_down.tags`, `orchestration.webhook.lambda.webhook.tags`, `orchestration.webhook.lambda.pool.tags`, `orchestration.webhook.job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `orchestration.webhook.lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain configuration-owned runner-config log-group tags. In stable mode, translation preserves the existing flat behavior. +For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `orchestration.webhook.queue.tags`, and configuration-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `orchestration.webhook.lambda.scale.up.tags`, `orchestration.webhook.lambda.scale.down.tags`, `orchestration.webhook.lambda.webhook.tags`, `orchestration.webhook.lambda.pool.tags`, `orchestration.webhook.job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `orchestration.webhook.lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain configuration-owned runner-config log-group tags. In stable mode, translation preserves the existing flat behavior. 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`, with demand-control resources canonically grouped under `orchestration.webhook`, while `runners_map` is empty. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].orchestration.webhook.scale_up.lambda`, `.log_group`, and `.role` for scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. Existing flat `scale_up`, `scale_down`, and `pool` output aliases remain available for compatibility. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,15 +167,15 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [random](#provider\_random) | ~> 3.0 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | +| [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | ## 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 | @@ -187,7 +187,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 | @@ -200,7 +200,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -216,7 +216,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`.
- `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration`, where exactly one typed provider block must be non-null.
- `orchestration.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration.webhook.lambda.scale.artifact`: Runner-config scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration.webhook.lambda.scale.artifact.zip`: Optional local path to the runner-config scale-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-config scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.scale.artifact.s3.key`: Object key of the runner-config scale-control-plane Lambda archive.
- `orchestration.webhook.lambda.scale.artifact.s3.object_version`: Optional object version of the runner-config scale-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration.webhook.lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration.webhook.lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration.webhook.lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration.webhook.lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale_up.timeout` that inherits it.
- `orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode.
- `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration.webhook.lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration.webhook.lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
repository_white_list = optional(list(string), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
maximum_count = optional(number, null)
}), {})

lambda = optional(object({
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = optional(object({
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

})), {})
})
| `{}` | 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration`, where exactly one typed provider block must be non-null.
- `orchestration.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

})), {})
})
| `{}` | 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
}))
})
| `null` | no | @@ -285,7 +285,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/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 5cab9da81f..bf3f5ff607 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -19,15 +19,12 @@ locals { runner = { os = null architecture = null - boot_time_in_minutes = 5 disable_default_labels = false extra_labels = [] group_name = "Default" name_prefix = "" run_as_root = false run_as = "ec2-user" - ephemeral = false - jit_config_enabled = null auto_update_disabled = false tags = {} hooks = { @@ -44,9 +41,8 @@ locals { } github = { - app = var.github_app - additional_apps = var.additional_github_apps - repository_white_list = var.repository_white_list + app = var.github_app + additional_apps = var.additional_github_apps enterprise_server = { url = var.ghes_url ssl_verify = var.ghes_ssl_verify @@ -78,36 +74,42 @@ locals { eventbridge = var.eventbridge matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier runner = { - maximum_count = null + boot_time_in_minutes = 5 + ephemeral = false + jit_config_enabled = null + maximum_count = null + } + github = { + repository_white_list = var.repository_white_list } lambda = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.runners_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.runners_lambda_s3_key + object_version = var.runners_lambda_s3_object_version + } + } scale = { - artifact = { - zip = var.lambda_s3_bucket == null ? var.runners_lambda_zip : null - s3 = var.lambda_s3_bucket == null ? null : { - key = var.runners_lambda_s3_key - object_version = var.runners_lambda_s3_object_version + up = { + memory_size = var.scale_up_lambda_memory_size + timeout = var.runners_scale_up_lambda_timeout + reserved_concurrent_executions = 1 + job_queued_check_enabled = null + event_source_mapping = { + 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 = {} } - } - scale_up = { - memory_size = var.scale_up_lambda_memory_size - timeout = var.runners_scale_up_lambda_timeout - reserved_concurrent_executions = 1 - job_queued_check_enabled = null - event_source_mapping = { - batch_size = var.lambda_event_source_mapping_batch_size - maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds + down = { + memory_size = var.scale_down_lambda_memory_size + timeout = var.runners_scale_down_lambda_timeout + schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = null + idle_config = [] + tags = {} } - tags = {} - } - scale_down = { - memory_size = var.scale_down_lambda_memory_size - timeout = var.runners_scale_down_lambda_timeout - schedule_expression = "cron(*/5 * * * ? *)" - minimum_running_time_in_minutes = null - idle_config = [] - tags = {} } webhook = { artifact = { @@ -298,15 +300,12 @@ locals { runner = { 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 - 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 = { @@ -342,7 +341,10 @@ locals { orchestration = { webhook = { runner = { - maximum_count = v.runner_config.runners_maximum_count + boot_time_in_minutes = v.runner_config.runner_boot_time_in_minutes + ephemeral = v.runner_config.enable_ephemeral_runners + jit_config_enabled = v.runner_config.enable_jit_config + maximum_count = v.runner_config.runners_maximum_count } github = { @@ -352,24 +354,26 @@ locals { matcherConfig = v.matcherConfig lambda = { - scale_up = { - memory_size = null - timeout = null - reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions - job_queued_check_enabled = v.runner_config.enable_job_queued_check - 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 + scale = { + up = { + memory_size = null + timeout = null + reserved_concurrent_executions = v.runner_config.scale_up_reserved_concurrent_executions + job_queued_check_enabled = v.runner_config.enable_job_queued_check + 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 + } + tags = {} + } + down = { + memory_size = null + timeout = null + 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 = {} } - tags = {} - } - scale_down = { - memory_size = null - timeout = null - 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 = { memory_size = null @@ -537,15 +541,12 @@ locals { runner = merge(v.runner, { os = try(coalesce(v.runner.os, local.raw_translated_experimental.runner.os), null) architecture = try(coalesce(v.runner.architecture, local.raw_translated_experimental.runner.architecture), null) - boot_time_in_minutes = coalesce(v.runner.boot_time_in_minutes, local.raw_translated_experimental.runner.boot_time_in_minutes) disable_default_labels = coalesce(v.runner.disable_default_labels, local.raw_translated_experimental.runner.disable_default_labels) extra_labels = v.runner.extra_labels != null ? v.runner.extra_labels : local.raw_translated_experimental.runner.extra_labels group_name = coalesce(v.runner.group_name, local.raw_translated_experimental.runner.group_name) name_prefix = v.runner.name_prefix != null ? v.runner.name_prefix : local.raw_translated_experimental.runner.name_prefix run_as_root = coalesce(v.runner.run_as_root, local.raw_translated_experimental.runner.run_as_root) run_as = coalesce(v.runner.run_as, local.raw_translated_experimental.runner.run_as) - ephemeral = coalesce(v.runner.ephemeral, local.raw_translated_experimental.runner.ephemeral) - jit_config_enabled = try(coalesce(v.runner.jit_config_enabled, local.raw_translated_experimental.runner.jit_config_enabled), null) auto_update_disabled = coalesce(v.runner.auto_update_disabled, local.raw_translated_experimental.runner.auto_update_disabled) tags = merge(local.raw_translated_experimental.runner.tags, v.runner.tags) hooks = { @@ -588,6 +589,18 @@ locals { orchestration = { webhook = v.orchestration.webhook == null ? null : merge(v.orchestration.webhook, { runner = { + boot_time_in_minutes = coalesce( + v.orchestration.webhook.runner.boot_time_in_minutes, + local.raw_translated_experimental.orchestration.webhook.runner.boot_time_in_minutes, + ) + ephemeral = coalesce( + v.orchestration.webhook.runner.ephemeral, + local.raw_translated_experimental.orchestration.webhook.runner.ephemeral, + ) + jit_config_enabled = try(coalesce( + v.orchestration.webhook.runner.jit_config_enabled, + local.raw_translated_experimental.orchestration.webhook.runner.jit_config_enabled, + ), null) maximum_count = try(coalesce( v.orchestration.webhook.runner.maximum_count, local.raw_translated_experimental.orchestration.webhook.runner.maximum_count, @@ -595,30 +608,32 @@ locals { } lambda = merge(v.orchestration.webhook.lambda, { - scale_up = merge(v.orchestration.webhook.lambda.scale_up, { - memory_size = coalesce(v.orchestration.webhook.lambda.scale_up.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.memory_size) - timeout = coalesce(v.orchestration.webhook.lambda.scale_up.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.timeout) - reserved_concurrent_executions = coalesce(v.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions) - job_queued_check_enabled = try(coalesce(v.orchestration.webhook.lambda.scale_up.job_queued_check_enabled, local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.job_queued_check_enabled), null) - event_source_mapping = { - batch_size = coalesce( - v.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size, - local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size, - ) - maximum_batching_window_in_seconds = coalesce( - v.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, - local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds, - ) - } - tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.tags, v.orchestration.webhook.lambda.scale_up.tags) - }) - scale_down = merge(v.orchestration.webhook.lambda.scale_down, { - memory_size = coalesce(v.orchestration.webhook.lambda.scale_down.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.memory_size) - timeout = coalesce(v.orchestration.webhook.lambda.scale_down.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.timeout) - schedule_expression = coalesce(v.orchestration.webhook.lambda.scale_down.schedule_expression, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.schedule_expression) - minimum_running_time_in_minutes = try(coalesce(v.orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes, local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes), null) - idle_config = v.orchestration.webhook.lambda.scale_down.idle_config != null ? v.orchestration.webhook.lambda.scale_down.idle_config : local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.idle_config - tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale_down.tags, v.orchestration.webhook.lambda.scale_down.tags) + scale = merge(v.orchestration.webhook.lambda.scale, { + up = merge(v.orchestration.webhook.lambda.scale.up, { + memory_size = coalesce(v.orchestration.webhook.lambda.scale.up.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.memory_size) + timeout = coalesce(v.orchestration.webhook.lambda.scale.up.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.timeout) + reserved_concurrent_executions = coalesce(v.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions) + job_queued_check_enabled = try(coalesce(v.orchestration.webhook.lambda.scale.up.job_queued_check_enabled, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.job_queued_check_enabled), null) + event_source_mapping = { + batch_size = coalesce( + v.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size, + local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size, + ) + maximum_batching_window_in_seconds = coalesce( + v.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds, + local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds, + ) + } + tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.tags, v.orchestration.webhook.lambda.scale.up.tags) + }) + down = merge(v.orchestration.webhook.lambda.scale.down, { + memory_size = coalesce(v.orchestration.webhook.lambda.scale.down.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.memory_size) + timeout = coalesce(v.orchestration.webhook.lambda.scale.down.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.timeout) + schedule_expression = coalesce(v.orchestration.webhook.lambda.scale.down.schedule_expression, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.schedule_expression) + minimum_running_time_in_minutes = try(coalesce(v.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes), null) + idle_config = v.orchestration.webhook.lambda.scale.down.idle_config != null ? v.orchestration.webhook.lambda.scale.down.idle_config : local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.idle_config + tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.tags, v.orchestration.webhook.lambda.scale.down.tags) + }) }) pool = merge(v.orchestration.webhook.lambda.pool, { memory_size = coalesce(v.orchestration.webhook.lambda.pool.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.pool.memory_size) @@ -781,7 +796,7 @@ locals { }) lambda = merge(v.orchestration.webhook.lambda, { - scale = local.translated_experimental_base.orchestration.webhook.lambda.scale + artifact = local.translated_experimental_base.orchestration.webhook.lambda.artifact }) }) } diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 49adaa958c..f1c0c9d68c 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -43,21 +43,21 @@ module "runners" { 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.orchestration.webhook.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.orchestration.webhook.lambda.scale_up.job_queued_check_enabled + enable_ephemeral_runners = each.value.orchestration.webhook.runner.ephemeral + enable_jit_config = each.value.orchestration.webhook.runner.jit_config_enabled + enable_job_queued_check = each.value.orchestration.webhook.lambda.scale.up.job_queued_check_enabled disable_runner_autoupdate = each.value.runner.auto_update_disabled enable_managed_runner_security_group = each.value.compute_provider.ec2.managed_security_group_enabled enable_runner_detailed_monitoring = each.value.compute_provider.ec2.detailed_monitoring_enabled - scale_down_schedule_expression = each.value.orchestration.webhook.lambda.scale_down.schedule_expression - minimum_running_time_in_minutes = each.value.orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.runner.boot_time_in_minutes + scale_down_schedule_expression = each.value.orchestration.webhook.lambda.scale.down.schedule_expression + minimum_running_time_in_minutes = each.value.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.orchestration.webhook.runner.boot_time_in_minutes runner_disable_default_labels = each.value.runner.disable_default_labels runner_labels = each.value.runner.labels runner_as_root = each.value.runner.run_as_root runner_run_as = each.value.runner.run_as runners_maximum_count = each.value.orchestration.webhook.runner.maximum_count - idle_config = each.value.orchestration.webhook.lambda.scale_down.idle_config + idle_config = each.value.orchestration.webhook.lambda.scale.down.idle_config enable_ssm_on_runners = each.value.compute_provider.ec2.ssm_enabled egress_rules = each.value.compute_provider.ec2.egress_rules runner_additional_security_group_ids = each.value.compute_provider.ec2.additional_security_group_ids @@ -69,18 +69,18 @@ module "runners" { 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 = local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket - runners_lambda_s3_key = try(local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.key, null) - runners_lambda_s3_object_version = try(local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.object_version, null) + lambda_s3_bucket = local.translated_experimental.orchestration.webhook.lambda.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + runners_lambda_s3_key = try(local.translated_experimental.orchestration.webhook.lambda.artifact.s3.key, null) + runners_lambda_s3_object_version = try(local.translated_experimental.orchestration.webhook.lambda.artifact.s3.object_version, null) lambda_runtime = each.value.lambda.runtime lambda_architecture = each.value.lambda.architecture - lambda_zip = local.translated_experimental.orchestration.webhook.lambda.scale.artifact.zip - lambda_scale_up_memory_size = each.value.orchestration.webhook.lambda.scale_up.memory_size - lambda_event_source_mapping_batch_size = each.value.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = each.value.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds - lambda_timeout_scale_up = each.value.orchestration.webhook.lambda.scale_up.timeout - lambda_scale_down_memory_size = each.value.orchestration.webhook.lambda.scale_down.memory_size - lambda_timeout_scale_down = each.value.orchestration.webhook.lambda.scale_down.timeout + lambda_zip = local.translated_experimental.orchestration.webhook.lambda.artifact.zip + lambda_scale_up_memory_size = each.value.orchestration.webhook.lambda.scale.up.memory_size + lambda_event_source_mapping_batch_size = each.value.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = each.value.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds + lambda_timeout_scale_up = each.value.orchestration.webhook.lambda.scale.up.timeout + lambda_scale_down_memory_size = each.value.orchestration.webhook.lambda.scale.down.memory_size + lambda_timeout_scale_down = each.value.orchestration.webhook.lambda.scale.down.timeout lambda_subnet_ids = each.value.lambda.subnet_ids lambda_security_group_ids = each.value.lambda.security_group_ids lambda_tags = each.value.lambda.tags @@ -95,7 +95,7 @@ module "runners" { runner_name_prefix = each.value.runner.name_prefix parameter_store_tags = each.value.ssm.parameters.tags - scale_up_reserved_concurrent_executions = each.value.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions + scale_up_reserved_concurrent_executions = each.value.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions instance_profile_path = each.value.compute_provider.ec2.instance_profile_path role_path = each.value.runner.iam.path diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 29cd470cc9..69235e419a 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,26 +2,26 @@ ## 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 | -|------|--------|---------| +| ---- | ------ | ------- | | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs @@ -31,7 +31,7 @@ No inputs. ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | | [runner\_config\_keys](#output\_runner\_config\_keys) | n/a | - \ No newline at end of file + diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf index 7ac79ec4ef..68635ece1e 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -52,11 +52,9 @@ module "multi_runner" { } lambda = { - scale = { - artifact = { - s3 = { - key = "nested-runners.zip" - } + artifact = { + s3 = { + key = "nested-runners.zip" } } diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing.tftest.hcl index 9b9ce6dce9..2869365299 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing.tftest.hcl @@ -328,7 +328,6 @@ run "stable_v1_keeps_legacy_runner_module" { && toset(keys(local.raw_translated_experimental.github)) == toset([ "app", "additional_apps", - "repository_white_list", "enterprise_server", "user_agent", ]) @@ -350,16 +349,29 @@ run "stable_v1_keeps_legacy_runner_module" { "eventbridge", "matcher_config_parameter_store_tier", "runner", + "github", "lambda", "queue", ]) + && toset(keys(local.raw_translated_experimental.orchestration.webhook.runner)) == toset([ + "boot_time_in_minutes", + "ephemeral", + "jit_config_enabled", + "maximum_count", + ]) + && toset(keys(local.raw_translated_experimental.orchestration.webhook.github)) == toset([ + "repository_white_list", + ]) && toset(keys(local.raw_translated_experimental.orchestration.webhook.lambda)) == toset([ + "artifact", "scale", - "scale_up", - "scale_down", "webhook", "pool", ]) + && toset(keys(local.raw_translated_experimental.orchestration.webhook.lambda.scale)) == toset([ + "up", + "down", + ]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"])) == toset([ "tags", "runner", @@ -389,10 +401,13 @@ run "stable_v1_keeps_legacy_runner_module" { "matcherConfig", ]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda)) == toset([ - "scale_up", - "scale_down", + "scale", "pool", ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale)) == toset([ + "up", + "down", + ]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue)) == toset([ "delay_webhook_event", "job_queue_retention_in_seconds", @@ -411,8 +426,15 @@ run "stable_v1_keeps_legacy_runner_module" { && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["ec2"]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].ssm), "kms_key_id") + && !contains(keys(local.raw_translated_experimental.runner), "boot_time_in_minutes") + && !contains(keys(local.raw_translated_experimental.runner), "ephemeral") + && !contains(keys(local.raw_translated_experimental.runner), "jit_config_enabled") && !contains(keys(local.raw_translated_experimental.runner), "maximum_count") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "ephemeral") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "maximum_count") + && local.raw_translated_experimental.orchestration.webhook.runner.boot_time_in_minutes == 5 && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"]), "scale_up") ) error_message = "Stable v1 must translate into the exact raw experimental schema before defaults, queue event-source mappings, binary artifacts, or runner-config component shapes are resolved." @@ -436,25 +458,25 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.roles.permissions_boundary == var.role_permissions_boundary && local.raw_translated_experimental.github.app == var.github_app && local.raw_translated_experimental.github.additional_apps == var.additional_github_apps - && local.raw_translated_experimental.github.repository_white_list == var.repository_white_list + && local.raw_translated_experimental.orchestration.webhook.github.repository_white_list == var.repository_white_list && local.raw_translated_experimental.github.enterprise_server.url == var.ghes_url && local.raw_translated_experimental.github.enterprise_server.ssl_verify == var.ghes_ssl_verify && local.raw_translated_experimental.github.user_agent == var.user_agent && local.raw_translated_experimental.orchestration.webhook.queue_selection_strategy == var.queue_selection_strategy && local.raw_translated_experimental.orchestration.webhook.eventbridge == var.eventbridge && local.raw_translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == var.matcher_config_parameter_store_tier - && local.raw_translated_experimental.orchestration.webhook.lambda.scale.artifact.zip == null + && local.raw_translated_experimental.orchestration.webhook.lambda.artifact.zip == null && local.raw_translated_experimental.lambda.artifact.s3.bucket == var.lambda_s3_bucket - && local.raw_translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.key == var.runners_lambda_s3_key - && local.raw_translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.object_version == var.runners_lambda_s3_object_version + && local.raw_translated_experimental.orchestration.webhook.lambda.artifact.s3.key == var.runners_lambda_s3_key + && local.raw_translated_experimental.orchestration.webhook.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version && local.raw_translated_experimental.lambda.runtime == var.lambda_runtime && local.raw_translated_experimental.lambda.architecture == var.lambda_architecture && local.raw_translated_experimental.lambda.principals == var.lambda_principals && local.raw_translated_experimental.lambda.subnet_ids == var.lambda_subnet_ids && local.raw_translated_experimental.lambda.security_group_ids == var.lambda_security_group_ids && local.raw_translated_experimental.lambda.tags == var.lambda_tags - && local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == var.lambda_event_source_mapping_batch_size - && local.raw_translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == var.lambda_event_source_mapping_maximum_batching_window_in_seconds + && local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == var.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == var.lambda_event_source_mapping_maximum_batching_window_in_seconds && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip == null && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.key == var.webhook_lambda_s3_key && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.object_version == var.webhook_lambda_s3_object_version @@ -517,9 +539,15 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == var.state_event_rule_binaries_syncer && local.raw_translated_experimental.multi_runner_config["linux"].runner.os == var.multi_runner_config["linux"].runner_config.runner_os && local.raw_translated_experimental.multi_runner_config["linux"].runner.architecture == var.multi_runner_config["linux"].runner_config.runner_architecture + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "ephemeral") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.boot_time_in_minutes == var.multi_runner_config["linux"].runner_config.runner_boot_time_in_minutes + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.ephemeral == var.multi_runner_config["linux"].runner_config.enable_ephemeral_runners + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.jit_config_enabled == var.multi_runner_config["linux"].runner_config.enable_jit_config && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.maximum_count == var.multi_runner_config["linux"].runner_config.runners_maximum_count && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.github.organization_runners == var.multi_runner_config["linux"].runner_config.enable_organization_runners - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == var.multi_runner_config["linux"].runner_config.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == var.multi_runner_config["linux"].runner_config.lambda_event_source_mapping_batch_size && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.delay_webhook_event == var.multi_runner_config["linux"].runner_config.delay_webhook_event && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.job_queue_retention_in_seconds == var.multi_runner_config["linux"].runner_config.job_queue_retention_in_seconds && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout @@ -981,10 +1009,8 @@ run "experimental_v2_routes_through_provider_stack" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -1046,12 +1072,14 @@ run "experimental_v2_routes_through_provider_stack" { } lambda = { - scale_down = { - idle_config = [{ - cron = "* * * * *" - timeZone = "UTC" - idleCount = 1 - }] + scale = { + down = { + idle_config = [{ + cron = "* * * * *" + timeZone = "UTC" + idleCount = 1 + }] + } } pool = { @@ -1155,30 +1183,35 @@ run "experimental_v2_routes_through_provider_stack" { length(local.translated_experimental.tags) == 0 && local.translated_experimental.roles.path == null && local.translated_experimental.roles.permissions_boundary == null - && local.translated_experimental.multi_runner_config["linux"].runner.boot_time_in_minutes == 5 && !local.translated_experimental.multi_runner_config["linux"].runner.disable_default_labels && local.translated_experimental.multi_runner_config["linux"].runner.group_name == "Default" && local.translated_experimental.multi_runner_config["linux"].runner.name_prefix == "" && !local.translated_experimental.multi_runner_config["linux"].runner.run_as_root && local.translated_experimental.multi_runner_config["linux"].runner.run_as == "ec2-user" - && !local.translated_experimental.multi_runner_config["linux"].runner.ephemeral - && local.translated_experimental.multi_runner_config["linux"].runner.jit_config_enabled == null && !local.translated_experimental.multi_runner_config["linux"].runner.auto_update_disabled && local.translated_experimental.multi_runner_config["linux"].runner.hooks.job_completed == "" && local.translated_experimental.multi_runner_config["linux"].runner.iam.path == null && local.translated_experimental.multi_runner_config["linux"].runner.iam.permissions_boundary == null + && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") + && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "ephemeral") + && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "maximum_count") + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.boot_time_in_minutes == 5 + && !local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.ephemeral + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.jit_config_enabled == null && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.maximum_count == 2 && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" + && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" + && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" ) - error_message = "Experimental v2 common runner defaults must stay provider-neutral while webhook-owned capacity reaches scale-up and pool without stable-input fallback." + error_message = "Experimental v2 common runner defaults must stay provider-neutral while webhook-owned lifecycle, capacity, and boot time reach their controls without stable-input fallback." } assert { condition = ( - local.translated_experimental.orchestration.webhook.lambda.scale.artifact.zip == "README.md" - && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3 == null + local.translated_experimental.orchestration.webhook.lambda.artifact.zip == "README.md" + && local.translated_experimental.orchestration.webhook.lambda.artifact.s3 == null && local.translated_experimental.lambda.artifact.s3.bucket == null && local.translated_experimental.multi_runner_config["linux"].lambda.artifact.s3.bucket == null && toset(keys(local.translated_experimental.multi_runner_config["linux"].lambda)) == toset([ @@ -1193,14 +1226,17 @@ run "experimental_v2_routes_through_provider_stack" { ]) && !contains(keys(local.translated_experimental.multi_runner_config["linux"].lambda), "zip") && !contains(keys(local.translated_experimental.multi_runner_config["linux"].lambda), "s3") - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.artifact.zip == "README.md" - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.artifact.s3 == null + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.artifact.zip == "README.md" + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.artifact.s3 == null && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda)) == toset([ + "artifact", "scale", - "scale_up", - "scale_down", "pool", ]) + && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale)) == toset([ + "up", + "down", + ]) && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue)) == toset([ "delay_webhook_event", "job_queue_retention_in_seconds", @@ -1222,14 +1258,14 @@ run "experimental_v2_routes_through_provider_stack" { && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.s3_bucket == null && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.memory_size == 512 && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.timeout == 30 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.reserved_concurrent_executions == 1 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.job_queued_check_enabled == null - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 10 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.reserved_concurrent_executions == 1 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.job_queued_check_enabled == null + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 10 + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.memory_size == 512 && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.timeout == 60 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_down.schedule_expression == "cron(*/5 * * * ? *)" - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes == null + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.down.schedule_expression == "cron(*/5 * * * ? *)" + && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes == null && module.runner_configs["linux"].orchestration.webhook.pool.lambda.memory_size == 512 && module.runner_configs["linux"].orchestration.webhook.pool.lambda.timeout == 60 && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.pool.reserved_concurrent_executions == 1 @@ -1263,7 +1299,7 @@ run "experimental_v2_routes_through_provider_stack" { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps - && length(local.translated_experimental.github.repository_white_list) == 0 + && length(local.translated_experimental.orchestration.webhook.github.repository_white_list) == 0 && !contains(keys(local.translated_experimental), "enterprise_server") && !contains(keys(local.translated_experimental), "user_agent") && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server @@ -1277,7 +1313,7 @@ run "experimental_v2_routes_through_provider_stack" { && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" ) - error_message = "V2 runner configurations must use concrete nested GitHub connection defaults rather than deliberately different flat GHES and user-agent inputs." + error_message = "V2 runner configurations must use concrete nested GitHub connection defaults and the webhook-owned repository allow-list rather than deliberately different flat inputs." } assert { @@ -1289,8 +1325,8 @@ run "experimental_v2_routes_through_provider_stack" { && local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip == "README.md" && local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3 == null && local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings == null - && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 10 - && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 10 + && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 ) error_message = "V2 webhook controls, the explicitly nested local artifact, API access-log defaults, and scale-up event-source mappings must avoid flat-input fallback." } @@ -1548,8 +1584,8 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = local.runner_config_by_provider.ec2["linux"].orchestration.webhook.lambda.scale_down.idle_config[0].idleCount == 1 - error_message = "Provider-neutral idle configuration must remain in the common runner contract." + condition = local.runner_config_by_provider.ec2["linux"].orchestration.webhook.lambda.scale.down.idle_config[0].idleCount == 1 + error_message = "Webhook-owned idle configuration must remain in the orchestration provider input contract." } assert { @@ -1717,7 +1753,6 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { key_base64 = "dGVzdA==" webhook_secret = "test-secret" } - repository_white_list = ["nested-owner/nested-repository"] enterprise_server = { url = "https://experimental-shared.example.com" ssl_verify = false @@ -1741,7 +1776,14 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { orchestration = { webhook = { runner = { - maximum_count = 2 + boot_time_in_minutes = 6 + ephemeral = true + jit_config_enabled = true + maximum_count = 2 + } + + github = { + repository_white_list = ["nested-owner/nested-repository"] } queue_selection_strategy = "all" @@ -1752,25 +1794,25 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { matcher_config_parameter_store_tier = "Advanced" lambda = { - scale = { - artifact = { - s3 = { - key = "nested-runners.zip" - object_version = "nested-runners-version" - } + artifact = { + s3 = { + key = "nested-runners.zip" + object_version = "nested-runners-version" } } - scale_up = { - memory_size = 768 - timeout = 40 - event_source_mapping = { - batch_size = 25 + scale = { + up = { + memory_size = 768 + timeout = 40 + event_source_mapping = { + batch_size = 25 + } } - } - scale_down = { - timeout = 75 + down = { + timeout = 75 + } } webhook = { @@ -1936,14 +1978,19 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { orchestration = { webhook = { runner = { - maximum_count = 4 + boot_time_in_minutes = 7 + ephemeral = false + jit_config_enabled = false + maximum_count = 4 } lambda = { - scale_up = { - memory_size = 896 - event_source_mapping = { - batch_size = 50 + scale = { + up = { + memory_size = 896 + event_source_mapping = { + batch_size = 50 + } } } @@ -1989,13 +2036,19 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { condition = ( local.translated_experimental.multi_runner_config["resolved"].runner.os == "linux" && local.translated_experimental.multi_runner_config["resolved"].runner.architecture == "x64" + && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].runner), "boot_time_in_minutes") + && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].runner), "ephemeral") + && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].runner), "jit_config_enabled") + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.boot_time_in_minutes == 7 + && !local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.ephemeral + && !local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.jit_config_enabled && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.maximum_count == 4 && local.translated_experimental.multi_runner_config["resolved"].runner.group_name == "lane-group" && local.translated_experimental.ssm.housekeeper.lambda.artifact.s3.key == "global-ssm-housekeeper.zip" && local.translated_experimental.multi_runner_config["resolved"].ssm.housekeeper.lambda.artifact.zip == "README.md" && local.translated_experimental.multi_runner_config["resolved"].ssm.housekeeper.lambda.artifact.s3 == null ) - error_message = "Common runner fields and webhook-owned capacity must resolve from their experimental global defaults before applying per-configuration overrides." + error_message = "Common runner fields and webhook-owned lifecycle, capacity, and boot time must resolve from their experimental global defaults before applying per-configuration overrides." } assert { @@ -2053,16 +2106,16 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { assert { condition = ( module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.runtime == "nodejs22.x" - && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.zip == null - && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.key == "nested-runners.zip" - && local.translated_experimental.orchestration.webhook.lambda.scale.artifact.s3.object_version == "nested-runners-version" + && local.translated_experimental.orchestration.webhook.lambda.artifact.zip == null + && local.translated_experimental.orchestration.webhook.lambda.artifact.s3.key == "nested-runners.zip" + && local.translated_experimental.orchestration.webhook.lambda.artifact.s3.object_version == "nested-runners-version" && local.translated_experimental.lambda.artifact.s3.bucket == "experimental-lambda-artifacts" && local.translated_experimental.multi_runner_config["resolved"].lambda.artifact.s3.bucket == "experimental-lambda-artifacts" && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].lambda), "zip") && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].lambda), "s3") - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.artifact.zip == null - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.artifact.s3.key == "nested-runners.zip" - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.artifact.s3.object_version == "nested-runners-version" + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.artifact.zip == null + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.artifact.s3.key == "nested-runners.zip" + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.artifact.s3.object_version == "nested-runners-version" && local.translated_experimental.lambda.principals == tolist([{ type = "Service" identifiers = tolist(["states.amazonaws.com"]) @@ -2074,7 +2127,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.timeout == 40 && module.runner_configs["resolved"].orchestration.webhook.scale_down.lambda.timeout == 75 && module.runner_configs["resolved"].orchestration.webhook.pool.lambda.memory_size == 448 - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 50 + && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 50 && module.runner_configs["resolved"].runner.role.path == "/experimental/" && module.runner_configs["resolved"].orchestration.webhook.scale_up.role.path == "/lane-lambda/" ) @@ -2119,6 +2172,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps + && local.translated_experimental.orchestration.webhook.github.repository_white_list == var.experimental.orchestration.webhook.github.repository_white_list && !contains(keys(local.translated_experimental), "enterprise_server") && !contains(keys(local.translated_experimental), "user_agent") && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server @@ -2192,11 +2246,11 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { && output.webhook.dispatcher == null && local.translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == "Advanced" && local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" - && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 25 - && local.translated_experimental.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 25 + && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 && !contains(keys(output.webhook.lambda.environment[0].variables), "GHES_URL") ) - error_message = "The shared webhook must consume nested GitHub, routing, eventbridge, matcher-tier, artifact, API-access-log, Lambda, and role globals without flat-input leakage." + error_message = "The shared webhook must consume its provider-owned repository allow-list plus nested GitHub connection, routing, eventbridge, matcher-tier, artifact, API-access-log, Lambda, and role globals without flat-input leakage." } } @@ -2279,10 +2333,8 @@ run "experimental_v2_layers_observability_and_ssm" { } lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -2912,10 +2964,8 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3011,10 +3061,8 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3108,10 +3156,8 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3213,10 +3259,8 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3316,10 +3360,8 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { } lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3404,10 +3446,8 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { } lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3495,10 +3535,8 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { } lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3579,10 +3617,8 @@ run "experimental_v2_external_role_ignores_global_iam_management" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3789,10 +3825,8 @@ run "experimental_v2_layers_shared_and_component_tags" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { @@ -3841,17 +3875,19 @@ run "experimental_v2_layers_shared_and_component_tags" { } lambda = { - scale_up = { - tags = { - ScaleUpOnly = "scale-up" - Precedence = "scale-up" + scale = { + up = { + tags = { + ScaleUpOnly = "scale-up" + Precedence = "scale-up" + } } - } - scale_down = { - tags = { - ScaleDownOnly = "scale-down" - Precedence = "scale-down" + down = { + tags = { + ScaleDownOnly = "scale-down" + Precedence = "scale-down" + } } } } @@ -4022,8 +4058,10 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window orchestration = { webhook = { lambda = { - scale_up = { - timeout = 40 + scale = { + up = { + timeout = 40 + } } } } @@ -4445,12 +4483,10 @@ run "experimental_v2_rejects_runner_artifact_zip_and_s3" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - s3 = { - key = "runners.zip" - } + artifact = { + zip = "README.md" + s3 = { + key = "runners.zip" } } } @@ -4521,11 +4557,9 @@ run "experimental_v2_rejects_runner_artifact_bucket_without_key" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - s3 = { - key = null - } + artifact = { + s3 = { + key = null } } } @@ -4588,11 +4622,9 @@ run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" - } + artifact = { + s3 = { + key = "runners.zip" } } } @@ -4995,10 +5027,8 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { orchestration = { webhook = { lambda = { - scale = { - artifact = { - zip = "README.md" - } + artifact = { + zip = "README.md" } webhook = { diff --git a/modules/multi-runner/validations.experimental.tf b/modules/multi-runner/validations.experimental.tf index f95d90565f..d3c98b89c4 100644 --- a/modules/multi-runner/validations.experimental.tf +++ b/modules/multi-runner/validations.experimental.tf @@ -85,6 +85,18 @@ resource "terraform_data" "validate_experimental" { error_message = "Each experimental runner configuration must resolve runner.os and runner.architecture from the configuration or experimental global runner defaults." } + precondition { + condition = alltrue([ + for runner_config in values(var.experimental.multi_runner_config) : + runner_config.orchestration.webhook == null ? true : + try(coalesce( + runner_config.orchestration.webhook.runner.boot_time_in_minutes, + var.experimental.orchestration.webhook.runner.boot_time_in_minutes, + ), null) != null + ]) + error_message = "Each experimental webhook runner configuration must resolve orchestration.webhook.runner.boot_time_in_minutes from the configuration or experimental global webhook defaults." + } + precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : @@ -116,12 +128,12 @@ resource "terraform_data" "validate_experimental" { runner_config.orchestration.webhook.queue.visibility_timeout_seconds, var.experimental.orchestration.webhook.queue.visibility_timeout_seconds, ) >= 6 * coalesce( - runner_config.orchestration.webhook.lambda.scale_up.timeout, - var.experimental.orchestration.webhook.lambda.scale_up.timeout, + runner_config.orchestration.webhook.lambda.scale.up.timeout, + var.experimental.orchestration.webhook.lambda.scale.up.timeout, ) ) ]) - error_message = "Each experimental orchestration.webhook.queue.visibility_timeout_seconds must be at least six times the resolved orchestration.webhook.lambda.scale_up.timeout." + error_message = "Each experimental orchestration.webhook.queue.visibility_timeout_seconds must be at least six times the resolved orchestration.webhook.lambda.scale.up.timeout." } precondition { @@ -266,16 +278,16 @@ resource "terraform_data" "validate_experimental" { precondition { condition = !local.use_multi_runner_config_v2 || ( !( - var.experimental.orchestration.webhook.lambda.scale.artifact.zip != null && - var.experimental.orchestration.webhook.lambda.scale.artifact.s3 != null + var.experimental.orchestration.webhook.lambda.artifact.zip != null && + var.experimental.orchestration.webhook.lambda.artifact.s3 != null ) && ( - var.experimental.orchestration.webhook.lambda.scale.artifact.s3 == null || ( + var.experimental.orchestration.webhook.lambda.artifact.s3 == null || ( var.experimental.lambda.artifact.s3.bucket != null && - try(var.experimental.orchestration.webhook.lambda.scale.artifact.s3.key != null, false) + try(var.experimental.orchestration.webhook.lambda.artifact.s3.key != null, false) ) ) ) - error_message = "experimental.orchestration.webhook.lambda.scale.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.orchestration.webhook.lambda.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index 175bc4ef54..79445278b9 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -15,15 +15,12 @@ variable "experimental" { - `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null. - `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally. - `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally. - - `runner.boot_time_in_minutes`: Default expected runner boot duration before a runner is considered stale. The default is `5`. - `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`. - `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`. - `runner.group_name`: Default GitHub runner group. The default is `Default`. - `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string. - `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`. - `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`. - - `runner.ephemeral`: Registers runners in ephemeral mode by default. The default is `false`. - - `runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode. - `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`. - `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`. - `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string. @@ -60,7 +57,6 @@ variable "experimental" { - `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper. - `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter. - `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter. - - `github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering. - `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null. - `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`. - `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`. @@ -80,29 +76,33 @@ variable "experimental" { - `orchestration.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. - `orchestration.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events. - `orchestration.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks. + - `orchestration.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering. + - `orchestration.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`. + - `orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`. + - `orchestration.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode. - `orchestration.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally. - - `orchestration.webhook.lambda.scale.artifact`: Runner-config scale-control-plane artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used. - - `orchestration.webhook.lambda.scale.artifact.zip`: Optional local path to the runner-config scale-control-plane Lambda archive. The default is null. - - `orchestration.webhook.lambda.scale.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-config scale-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key. - - `orchestration.webhook.lambda.scale.artifact.s3.key`: Object key of the runner-config scale-control-plane Lambda archive. - - `orchestration.webhook.lambda.scale.artifact.s3.object_version`: Optional object version of the runner-config scale-control-plane Lambda archive. The default is null. - - `orchestration.webhook.lambda.scale_up.memory_size`: Scale-up Lambda memory in MB. The default is `512`. - - `orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`. - - `orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. - - `orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. - - `orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. - - `orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`. - - `orchestration.webhook.lambda.scale_up.tags`: Default tags for scale-up resources. The default is `{}`. - - `orchestration.webhook.lambda.scale_down.memory_size`: Scale-down Lambda memory in MB. The default is `512`. - - `orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. - - `orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`. - - `orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. - - `orchestration.webhook.lambda.scale_down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`. - - `orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - - `orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. - - `orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners retained during the matching period. - - `orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. - - `orchestration.webhook.lambda.scale_down.tags`: Default tags for scale-down resources. The default is `{}`. + - `orchestration.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used. + - `orchestration.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null. + - `orchestration.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key. + - `orchestration.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive. + - `orchestration.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null. + - `orchestration.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`. + - `orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`. + - `orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. + - `orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. + - `orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. + - `orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`. + - `orchestration.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`. + - `orchestration.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`. + - `orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. + - `orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`. + - `orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. + - `orchestration.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`. + - `orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. + - `orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period. + - `orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. + - `orchestration.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`. - `orchestration.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. - `orchestration.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null. - `orchestration.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key. @@ -126,7 +126,7 @@ variable "experimental" { - `orchestration.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`. - `orchestration.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`. - `orchestration.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`. - - `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale_up.timeout` that inherits it. + - `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale.up.timeout` that inherits it. - `orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`. - `orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled. - `orchestration.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`. @@ -249,15 +249,12 @@ variable "experimental" { - `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations. - `multi_runner_config[].runner.os`: Runner operating system. - `multi_runner_config[].runner.architecture`: Runner distribution architecture. - - `multi_runner_config[].runner.boot_time_in_minutes`: Expected boot duration used before a runner is considered stale. - `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered. - `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true. - `multi_runner_config[].runner.group_name`: GitHub runner group used during registration. - `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names. - `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider. - `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false. - - `multi_runner_config[].runner.ephemeral`: Registers runners in ephemeral mode. - - `multi_runner_config[].runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global value; if both are null, behavior follows the resolved `ephemeral` mode. - `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. - `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`. - `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook. @@ -278,6 +275,9 @@ variable "experimental" { - `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`. - `multi_runner_config[].orchestration`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract. - `multi_runner_config[].orchestration.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources. + - `multi_runner_config[].orchestration.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration.webhook.runner.boot_time_in_minutes`. + - `multi_runner_config[].orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration.webhook.runner.ephemeral`. + - `multi_runner_config[].orchestration.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode. - `multi_runner_config[].orchestration.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration.webhook.runner.maximum_count`. - `multi_runner_config[].orchestration.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used. - `multi_runner_config[].orchestration.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration. @@ -293,27 +293,27 @@ variable "experimental" { - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. - `multi_runner_config[].orchestration.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default. - `multi_runner_config[].orchestration.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default. - - `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale_up.timeout` so Lambda has enough time to retry throttled invocations. + - `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations. - `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value. - `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled. - `multi_runner_config[].orchestration.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map. - - `multi_runner_config[].orchestration.webhook.lambda.scale_up.memory_size`: Memory allocated to the scale-up Lambda in MB. - - `multi_runner_config[].orchestration.webhook.lambda.scale_up.timeout`: Scale-up Lambda timeout in seconds. - - `multi_runner_config[].orchestration.webhook.lambda.scale_up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. - - `multi_runner_config[].orchestration.webhook.lambda.scale_up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode. - - `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. - - `multi_runner_config[].orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. - - `multi_runner_config[].orchestration.webhook.lambda.scale_up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.memory_size`: Memory allocated to the scale-down Lambda in MB. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.timeout`: Scale-down Lambda timeout in seconds. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.schedule_expression`: EventBridge schedule expression that invokes scale-down. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config`: Time-based desired idle-runner configurations. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].idleCount`: Number of idle runners to retain during the matching period. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. - - `multi_runner_config[].orchestration.webhook.lambda.scale_down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. + - `multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. + - `multi_runner_config[].orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode. + - `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. + - `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. + - `multi_runner_config[].orchestration.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. + - `multi_runner_config[].orchestration.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. - `multi_runner_config[].orchestration.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. - `multi_runner_config[].orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. - `multi_runner_config[].orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. @@ -459,15 +459,12 @@ variable "experimental" { runner = optional(object({ os = optional(string, null) architecture = optional(string, null) - 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") - ephemeral = optional(bool, false) - jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, false) tags = optional(map(string), {}) hooks = optional(object({ @@ -511,7 +508,6 @@ variable "experimental" { installation_id = optional(string) installation_id_ssm = optional(object({ arn = string, name = string })) })), []) - repository_white_list = optional(list(string), []) enterprise_server = optional(object({ url = optional(string, null) ssl_verify = optional(bool, true) @@ -549,42 +545,49 @@ variable "experimental" { }), {}) matcher_config_parameter_store_tier = optional(string, "Standard") runner = optional(object({ - maximum_count = optional(number, null) + boot_time_in_minutes = optional(number, 5) + ephemeral = optional(bool, false) + jit_config_enabled = optional(bool, null) + maximum_count = optional(number, null) + }), {}) + + github = optional(object({ + repository_white_list = optional(list(string), []) }), {}) lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) scale = optional(object({ - artifact = optional(object({ - zip = optional(string, null) - s3 = optional(object({ - key = string - object_version = optional(string, null) - }), null) + up = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 30) + reserved_concurrent_executions = optional(number, 1) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, 10) + maximum_batching_window_in_seconds = optional(number, 0) + }), {}) + tags = optional(map(string), {}) }), {}) - }), {}) - scale_up = optional(object({ - memory_size = optional(number, 512) - timeout = optional(number, 30) - reserved_concurrent_executions = optional(number, 1) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, 10) - maximum_batching_window_in_seconds = optional(number, 0) + down = optional(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") + })), []) + tags = optional(map(string), {}) }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(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") - })), []) - tags = optional(map(string), {}) }), {}) webhook = optional(object({ artifact = optional(object({ @@ -830,15 +833,12 @@ variable "experimental" { runner = optional(object({ os = optional(string, null) architecture = optional(string, null) - boot_time_in_minutes = optional(number, null) disable_default_labels = optional(bool, null) extra_labels = optional(list(string), null) group_name = optional(string, null) name_prefix = optional(string, null) run_as_root = optional(bool, null) run_as = optional(string, null) - ephemeral = optional(bool, null) - jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, null) tags = optional(map(string), {}) hooks = optional(object({ @@ -871,7 +871,10 @@ variable "experimental" { orchestration = object({ webhook = optional(object({ runner = optional(object({ - maximum_count = optional(number, null) + boot_time_in_minutes = optional(number, null) + ephemeral = optional(bool, null) + jit_config_enabled = optional(bool, null) + maximum_count = optional(number, null) }), {}) github = optional(object({ @@ -906,29 +909,31 @@ variable "experimental" { }), {}) lambda = optional(object({ - scale_up = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) + scale = optional(object({ + up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - schedule_expression = optional(string, null) - minimum_running_time_in_minutes = optional(number, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), null) - tags = optional(map(string), {}) }), {}) pool = optional(object({ memory_size = optional(number, null) diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index dba61ffdff..89b74f6806 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -49,7 +49,7 @@ module "webhook" { role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) - repository_white_list = local.translated_experimental.github.repository_white_list + repository_white_list = local.translated_experimental.orchestration.webhook.github.repository_white_list queue_selection_strategy = local.translated_experimental.orchestration.webhook.queue_selection_strategy lambda_subnet_ids = local.translated_experimental.lambda.subnet_ids diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index bb6c288567..4a38f8a2ee 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -1,6 +1,6 @@ # Webhook orchestration provider -This internal module owns the event-driven runner demand controls used by `runner-config`: scale-up, scale-down, scheduled pool reconciliation, and optional queued-job retry. It receives the common GitHub, Lambda, runner-registration, SSM, observability, and selected compute-provider contracts from the parent configuration module, then resolves webhook-specific defaults and tag precedence before invoking its leaf modules. Its `config.runner.maximum_count` capacity limit is forwarded to both scale-up and pool, without a common-runner fallback. It also combines the shared Lambda artifact bucket with its own `config.lambda.scale.artifact` zip or S3 key/version; provider-specific artifact fields do not leak into the common Lambda contract. +This internal module owns the event-driven runner demand controls used by `runner-config`: scale-up, scale-down, scheduled pool reconciliation, and optional queued-job retry. It receives the common GitHub, Lambda, runner-registration, SSM, observability, and selected compute-provider contracts from the parent configuration module, then resolves webhook-specific defaults and tag precedence before invoking its leaf modules. Lifecycle, boot time, and capacity are provider-owned under `config.runner`; the provider resolves the lifecycle contract for runner bootstrap, forwards capacity to scale-up and pool, and forwards boot time to scale-down and pool. It also combines the shared Lambda artifact bucket with its own `config.lambda.artifact` zip or S3 key/version shared by scale, pool, and job-retry; provider-specific artifact fields do not leak into the common Lambda contract. `runner-config` selects this provider when `orchestration.webhook` is the one populated orchestration block. The parent continues to own common runner resources, shared SSM configuration, and compute-provider selection. A future orchestration provider should be implemented as a sibling module with the same parent-facing resource boundary; it should not add its stateful resources to this webhook module. @@ -10,7 +10,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | @@ -21,7 +21,7 @@ No providers. ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | | [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | @@ -33,14 +33,14 @@ No resources. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | -| [config](#input\_config) | Resolved provider-owned values from orchestration.webhook, including the runner capacity limit used by scaling and pool controls. |
object({
runner = object({
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
scale = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(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 = optional(map(string), {})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | +| [config](#input\_config) | Resolved provider-owned values from orchestration.webhook, including runner lifecycle, boot timeout, and capacity limits used by scaling and pool controls. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | | [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection. |
object({
artifact = object({
s3 = object({
bucket = optional(string, null)
})
})
runtime = string
architecture = string
subnet_ids = list(string)
security_group_ids = list(string)
tags = optional(map(string), {})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
| n/a | yes | | [observability](#input\_observability) | Common logging, tracing, and metrics configuration consumed by webhook controls. |
object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
tags = optional(map(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
})
})
})
| n/a | yes | | [prefix](#input\_prefix) | Prefix used to identify resources created for this webhook orchestration provider. | `string` | n/a | yes | -| [runner](#input\_runner) | Common runner registration values consumed by webhook demand controls. Capacity remains provider-owned under config.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
})
| n/a | yes | +| [runner](#input\_runner) | Common runner registration values consumed by webhook demand controls. Lifecycle, boot timeout, and capacity remain provider-owned under config.runner. |
object({
os = string
auto_update_disabled = bool
labels = list(string)
group_name = string
name_prefix = string
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Selected compute-provider capabilities consumed by webhook scaling, pool, and retry controls. |
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
})
pool = object({
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
})
| n/a | yes | | [ssm](#input\_ssm) | Resolved Parameter Store paths, optional decrypt key, and runtime parameter tags. |
object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
kms_key_id = optional(string, null)
parameter_store_tags = string
})
| n/a | yes | | [tags](#input\_tags) | Base tags available to webhook-provider resources. Component-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | @@ -48,9 +48,10 @@ No resources. ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [job\_retry](#output\_job\_retry) | Job-retry resources. Null when job retry is disabled. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool schedule is configured. | +| [runner\_lifecycle](#output\_runner\_lifecycle) | Effective webhook-owned runner lifecycle consumed by runner-config bootstrap parameters. | | [scale\_down](#output\_scale\_down) | Scale-down control-plane resources. | | [scale\_up](#output\_scale\_up) | Scale-up control-plane resources. | diff --git a/modules/orchestration-providers/webhook/main.tf b/modules/orchestration-providers/webhook/main.tf index 44a67bc55e..d9b1722d98 100644 --- a/modules/orchestration-providers/webhook/main.tf +++ b/modules/orchestration-providers/webhook/main.tf @@ -1,33 +1,37 @@ locals { - packaged_runners_lambda_zip = "${path.module}/../../../lambdas/functions/control-plane/runners.zip" - scale_artifact_s3_selected = var.config.lambda.scale.artifact.s3 != null - scale_artifact = { - zip = local.scale_artifact_s3_selected ? null : coalesce( - var.config.lambda.scale.artifact.zip, + packaged_runners_lambda_zip = "${path.module}/../../../lambdas/functions/control-plane/runners.zip" + runner_control_artifact_s3_selected = var.config.lambda.artifact.s3 != null + runner_control_artifact = { + zip = local.runner_control_artifact_s3_selected ? null : coalesce( + var.config.lambda.artifact.zip, local.packaged_runners_lambda_zip, ) s3 = { - bucket = local.scale_artifact_s3_selected ? var.lambda.artifact.s3.bucket : null - key = try(var.config.lambda.scale.artifact.s3.key, null) - object_version = try(var.config.lambda.scale.artifact.s3.object_version, null) + bucket = local.runner_control_artifact_s3_selected ? var.lambda.artifact.s3.bucket : null + key = try(var.config.lambda.artifact.s3.key, null) + object_version = try(var.config.lambda.artifact.s3.object_version, null) } } resolved_config = { prefix = var.prefix tags = var.tags - runner = merge(var.runner, { - maximum_count = var.config.runner.maximum_count + runner = merge(var.runner, var.config.runner, { + jit_config_enabled = ( + var.config.runner.jit_config_enabled == null + ? var.config.runner.ephemeral + : var.config.runner.jit_config_enabled + ) }) github = merge(var.github, var.config.github) lambda = merge(var.lambda, { - artifact = local.scale_artifact + artifact = local.runner_control_artifact }) queue = merge(var.config.queue, { - event_source_mapping = var.config.lambda.scale_up.event_source_mapping + event_source_mapping = var.config.lambda.scale.up.event_source_mapping }) - scale_up = var.config.lambda.scale_up - scale_down = var.config.lambda.scale_down + scale_up = var.config.lambda.scale.up + scale_down = var.config.lambda.scale.down pool = var.config.lambda.pool job_retry = var.config.job_retry ssm = var.ssm diff --git a/modules/orchestration-providers/webhook/outputs.tf b/modules/orchestration-providers/webhook/outputs.tf index 4c5e1008f4..0fb477ba7d 100644 --- a/modules/orchestration-providers/webhook/outputs.tf +++ b/modules/orchestration-providers/webhook/outputs.tf @@ -20,3 +20,11 @@ output "job_retry" { queue = one(module.job_retry[*].job_retry_check_queue) } : null } + +output "runner_lifecycle" { + description = "Effective webhook-owned runner lifecycle consumed by runner-config bootstrap parameters." + value = { + ephemeral = local.resolved_config.runner.ephemeral + jit_config_enabled = local.resolved_config.runner.jit_config_enabled + } +} diff --git a/modules/orchestration-providers/webhook/pool.tf b/modules/orchestration-providers/webhook/pool.tf index e6d3d35d80..6fb9ad3d34 100644 --- a/modules/orchestration-providers/webhook/pool.tf +++ b/modules/orchestration-providers/webhook/pool.tf @@ -43,6 +43,7 @@ module "pool" { group_name = local.resolved_config.runner.group_name name_prefix = local.resolved_config.runner.name_prefix pool_owner = local.resolved_config.pool.runner_owner + boot_time_in_minutes = local.resolved_config.runner.boot_time_in_minutes } ssm_token_path = local.resolved_config.ssm.token_path ssm_token_path_arn = local.resolved_config.ssm.token_path_arn diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index da0acb7ebb..a7be3fa08b 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/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.21 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.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 | @@ -50,15 +50,15 @@ 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 webhook orchestration provider 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = 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 | +| [config](#input\_config) | Configuration passed from the webhook orchestration provider 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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
boot_time_in_minutes = number
})
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_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = 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 | -|------|-------------| +| ---- | ----------- | | [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/orchestration-providers/webhook/pool/pool.tf b/modules/orchestration-providers/webhook/pool/pool.tf index 200c3fbc1c..cff2776e90 100644 --- a/modules/orchestration-providers/webhook/pool/pool.tf +++ b/modules/orchestration-providers/webhook/pool/pool.tf @@ -23,6 +23,7 @@ locals { RUNNER_GROUP_NAME = var.config.runner.group_name RUNNER_NAME_PREFIX = var.config.runner.name_prefix RUNNER_OWNER = var.config.runner.pool_owner + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count SSM_TOKEN_PATH = var.config.ssm_token_path SSM_CONFIG_PATH = var.config.ssm_config_path diff --git a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl index 1cd352ded4..d65ca41b82 100644 --- a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl +++ b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl @@ -74,6 +74,7 @@ variables { group_name = "default" name_prefix = "microvm" pool_owner = "example" + boot_time_in_minutes = 13 } runners_maximum_count = 10 prefix = "pool-test" @@ -138,8 +139,9 @@ run "provider_supplies_only_compute_specific_pool_configuration" { condition = ( aws_lambda_function.pool.environment[0].variables["RUNNER_OWNER"] == "example" && aws_lambda_function.pool.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "10" + && aws_lambda_function.pool.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "13" ) - error_message = "The pool module must assemble common runner registration values and the webhook-provider capacity limit." + error_message = "The pool module must assemble common runner registration values and webhook-provider capacity and boot-time settings." } assert { diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf index 905c823db7..6337b0ea5f 100644 --- a/modules/orchestration-providers/webhook/pool/variables.tf +++ b/modules/orchestration-providers/webhook/pool/variables.tf @@ -36,6 +36,7 @@ variable "config" { - `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. + - `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation. - `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda. - `prefix`: Prefix used to name pool resources. - `pool`: Scheduled pool targets. @@ -95,6 +96,7 @@ variable "config" { group_name = string name_prefix = string pool_owner = string + boot_time_in_minutes = number }) runners_maximum_count = number prefix = string diff --git a/modules/orchestration-providers/webhook/scale-down-state-diagram.md b/modules/orchestration-providers/webhook/scale-down-state-diagram.md index 64e32bc141..dec780cfbf 100644 --- a/modules/orchestration-providers/webhook/scale-down-state-diagram.md +++ b/modules/orchestration-providers/webhook/scale-down-state-diagram.md @@ -146,5 +146,5 @@ stateDiagram-v2 - **Cron Schedule**: `cron(*/5 * * * ? *)` (every 5 minutes) - **Minimum Runtime**: Linux 5min, Windows 15min, OSX 20min -- **Boot Timeout**: Configurable via `runner_boot_time_in_minutes` +- **Boot Timeout**: Configurable via `orchestration.webhook.runner.boot_time_in_minutes`; stable-v1 inputs are translated from `runner_boot_time_in_minutes`. - **Idle Config**: Per-environment configuration for desired idle runners diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index a83aa44407..ef156e5808 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -10,15 +10,15 @@ The module is an implementation detail of the experimental runner configuration. ## 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.60.0 | ## 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,15 +63,15 @@ 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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume 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`: Webhook-provider runner capacity limit for this runner configuration.
- `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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = 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
})
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 | +| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume 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.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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
boot_time_in_minutes = number
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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = 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
})
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/orchestration-providers/webhook/scale-runners/scale-down.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf index a118f31e58..4951363bc9 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf @@ -33,6 +33,7 @@ resource "aws_lambda_function" "scale_down" { POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error COMPUTE_PROVIDER_TYPE = var.runner_provider.type + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes }) } diff --git a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl index 16e4c4bffa..955c9e7182 100644 --- a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl @@ -49,6 +49,7 @@ variables { labels = ["Self-Hosted", "MicroVM"] group_name = "test-group" name_prefix = "test-runner-" + boot_time_in_minutes = 12 maximum_count = 7 } github = { @@ -236,11 +237,12 @@ run "assembles_provider_neutral_scaling_control_plane" { aws_lambda_function.scale_up.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "microvm" && aws_lambda_function.scale_down.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "microvm" && aws_lambda_function.scale_up.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "7" + && aws_lambda_function.scale_down.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "12" && 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." + error_message = "The common scaling Lambdas must select the compute provider while injecting webhook-owned capacity and boot-time settings." } assert { diff --git a/modules/orchestration-providers/webhook/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf index 0e88af4e5b..f44c063e9d 100644 --- a/modules/orchestration-providers/webhook/scale-runners/variables.tf +++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf @@ -27,6 +27,7 @@ variable "config" { - `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.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down. - `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration. - `github.organization_runners`: Registers organization runners when true. - `github.enterprise_server.url`: Optional GitHub Enterprise Server URL. @@ -98,6 +99,7 @@ variable "config" { labels = list(string) group_name = string name_prefix = string + boot_time_in_minutes = number maximum_count = number }) github = object({ diff --git a/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl index 1a0db2880d..105648e8be 100644 --- a/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl +++ b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl @@ -23,8 +23,6 @@ variables { runner = { os = "linux" auto_update_disabled = false - ephemeral = true - jit_config_enabled = true labels = ["self-hosted", "linux"] group_name = "default" name_prefix = "webhook-test-" @@ -101,7 +99,10 @@ variables { config = { runner = { - maximum_count = 10 + boot_time_in_minutes = 11 + ephemeral = true + jit_config_enabled = null + maximum_count = 10 } github = { organization_runners = true @@ -117,35 +118,35 @@ variables { } } lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" - } + artifact = { + s3 = { + key = "runners.zip" } } - scale_up = { - memory_size = 512 - timeout = 60 - reserved_concurrent_executions = 1 - job_queued_check_enabled = null - event_source_mapping = { - batch_size = 10 - maximum_batching_window_in_seconds = 0 - } - tags = { - ScaleUp = "yes" - Precedence = "scale-up" + scale = { + up = { + memory_size = 512 + timeout = 60 + reserved_concurrent_executions = 1 + job_queued_check_enabled = null + event_source_mapping = { + batch_size = 10 + maximum_batching_window_in_seconds = 0 + } + tags = { + ScaleUp = "yes" + Precedence = "scale-up" + } } - } - scale_down = { - memory_size = 512 - timeout = 60 - schedule_expression = "cron(*/5 * * * ? *)" - minimum_running_time_in_minutes = null - idle_config = [] - tags = { - ScaleDown = "yes" + down = { + memory_size = 512 + timeout = 60 + schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = null + idle_config = [] + tags = { + ScaleDown = "yes" + } } } pool = { @@ -242,8 +243,20 @@ run "owns_webhook_control_plane" { condition = ( output.scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "10" && output.pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "10" + && output.scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "11" + && output.pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "11" + && output.scale_up.lambda.environment[0].variables["ENABLE_JIT_CONFIG"] == "true" + && output.pool.lambda.environment[0].variables["ENABLE_JIT_CONFIG"] == "true" + ) + error_message = "The webhook provider must route its provider-owned runner lifecycle, capacity, and boot-time values without reading them from common runner values." + } + + assert { + condition = ( + output.runner_lifecycle.ephemeral + && output.runner_lifecycle.jit_config_enabled ) - error_message = "The webhook provider must route its provider-owned runner capacity limit to scale-up and pool without reading it from common runner values." + error_message = "The webhook provider must expose its resolved lifecycle contract and default JIT configuration to the effective ephemeral mode." } assert { @@ -253,7 +266,7 @@ run "owns_webhook_control_plane" { && output.scale_down.lambda.s3_bucket == "lambda-artifacts" && output.pool.lambda.s3_key == "runners.zip" ) - error_message = "The webhook provider must combine the common artifact bucket with its provider-owned scale artifact key." + error_message = "The webhook provider must combine the common artifact bucket with its shared runner-control artifact key for scale, pool, and job retry." } assert { diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf index 74f0935961..dcb6f0b0d8 100644 --- a/modules/orchestration-providers/webhook/variables.tf +++ b/modules/orchestration-providers/webhook/variables.tf @@ -16,10 +16,13 @@ variable "tags" { } variable "config" { - description = "Resolved provider-owned values from orchestration.webhook, including the runner capacity limit used by scaling and pool controls." + description = "Resolved provider-owned values from orchestration.webhook, including runner lifecycle, boot timeout, and capacity limits used by scaling and pool controls." type = object({ runner = object({ - maximum_count = number + boot_time_in_minutes = number + ephemeral = bool + jit_config_enabled = optional(bool, null) + maximum_count = number }) github = object({ organization_runners = bool @@ -33,38 +36,38 @@ variable "config" { tags = optional(map(string), {}) }) lambda = object({ + artifact = object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }) scale = object({ - artifact = object({ - zip = optional(string, null) - s3 = optional(object({ - key = string - object_version = optional(string, null) - }), null) + up = object({ + memory_size = number + timeout = number + reserved_concurrent_executions = number + job_queued_check_enabled = optional(bool, null) + event_source_mapping = object({ + batch_size = number + maximum_batching_window_in_seconds = number + }) + tags = optional(map(string), {}) }) - }) - scale_up = object({ - memory_size = number - timeout = number - reserved_concurrent_executions = number - job_queued_check_enabled = optional(bool, null) - event_source_mapping = object({ - batch_size = number - maximum_batching_window_in_seconds = number + 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 = optional(map(string), {}) }) - tags = optional(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 = optional(map(string), {}) }) pool = object({ memory_size = number @@ -97,20 +100,18 @@ variable "config" { validation { condition = !( - var.config.lambda.scale.artifact.zip != null && - var.config.lambda.scale.artifact.s3 != null + var.config.lambda.artifact.zip != null && + var.config.lambda.artifact.s3 != null ) - error_message = "config.lambda.scale.artifact must select at most one of zip or s3." + error_message = "config.lambda.artifact must select at most one of zip or s3." } } variable "runner" { - description = "Common runner registration values consumed by webhook demand controls. Capacity remains provider-owned under config.runner." + description = "Common runner registration values consumed by webhook demand controls. Lifecycle, boot timeout, and capacity remain provider-owned under config.runner." type = object({ os = string auto_update_disabled = bool - ephemeral = bool - jit_config_enabled = optional(bool, null) labels = list(string) group_name = string name_prefix = string diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index a9ce345933..faf9c70118 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -6,9 +6,9 @@ This internal module implements the experimental provider-neutral runner configu The module selects the [`webhook` orchestration provider](../orchestration-providers/webhook), which owns its [`scale-runners`](../orchestration-providers/webhook/scale-runners), [`pool`](../orchestration-providers/webhook/pool), and [`job-retry`](../orchestration-providers/webhook/job-retry) leaves. The configuration module retains the common [`ssm-housekeeper`](./ssm-housekeeper), creates or selects the runner IAM role, manages shared runner configuration in SSM, and dispatches the selected compute provider. -Runner demand orchestration is selected independently through `orchestration`. `orchestration.webhook` is the currently supported provider and owns the build queue reference, `orchestration.webhook.runner.maximum_count` capacity limit, runner registration scope, scaling controls, scheduled pool, and job retry. Common `runner` contains no demand-provider capacity setting. The provider wrapper is nullable so a future sibling provider can be added without moving this webhook contract again, while validation requires exactly one provider to be selected. +Runner demand orchestration is selected independently through `orchestration`. `orchestration.webhook` is the currently supported provider and owns the build queue reference; runner lifecycle, boot time, and capacity under `orchestration.webhook.runner`; runner registration scope; scaling controls; scheduled pool; and job retry. Common `runner` contains no webhook lifecycle or capacity settings. The provider resolves its lifecycle contract before runner-config serializes the existing bootstrap parameters. The provider wrapper is nullable so a future sibling provider can be added without moving this webhook contract again, while validation requires exactly one provider to be selected. -Common `lambda` contains only shared execution substrate and the optional shared artifact bucket. The webhook provider owns its scale-control archive selection at `orchestration.webhook.lambda.scale.artifact` and combines its zip or S3 key/version with that common substrate. The common SSM housekeeper independently owns `ssm.housekeeper.lambda.artifact`: an S3 selection combines its component key/version with the common bucket, a local zip is used otherwise when configured, and the packaged runner control-plane archive is the final fallback. It never inherits the webhook scale archive. +Common `lambda` contains only shared execution substrate and the optional shared artifact bucket. The webhook provider owns the runner-control archive shared by scale, pool, and job-retry at `orchestration.webhook.lambda.artifact` and combines its zip or S3 key/version with that common substrate. The common SSM housekeeper independently owns `ssm.housekeeper.lambda.artifact`: an S3 selection combines its component key/version with the common bucket, a local zip is used otherwise when configured, and the packaged runner control-plane archive is the final fallback. It never inherits the webhook runner-control archive. Provider-owned settings remain nested under a typed provider block. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. `multi-runner` resolves experimental globals and runner-configuration overrides first, then its final forwarding adapter preserves the wrapped `{ ec2 = ... }` object expected by this module. Exactly one provider block must be non-null, and the configuration module derives its provider type from that block rather than from a separate discriminator. @@ -16,9 +16,9 @@ The EC2 block reaches runner-config with `compute_provider.ec2.binaries_syncer = ## Tagging -`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `orchestration.webhook.queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `orchestration.webhook.lambda.scale_up`, `orchestration.webhook.lambda.scale_down`, `orchestration.webhook.lambda.pool`, `orchestration.webhook.job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes. +`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `orchestration.webhook.queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `orchestration.webhook.lambda.scale.up`, `orchestration.webhook.lambda.scale.down`, `orchestration.webhook.lambda.pool`, `orchestration.webhook.job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes. -Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `orchestration.webhook.lambda.scale_up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `orchestration.webhook.lambda.scale_up.tags`. +Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `orchestration.webhook.lambda.scale.up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `orchestration.webhook.lambda.scale.up.tags`. Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and runner-configuration `compute_provider.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. @@ -34,7 +34,7 @@ The scale up lambda is triggered by events on a SQS queue. Events on this queue ### Lambda scale down -The scale down lambda is triggered via a CloudWatch event. The event is triggered by a cron expression defined in `orchestration.webhook.lambda.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. +The scale down lambda is triggered via a CloudWatch event. The event is triggered by a cron expression defined in `orchestration.webhook.lambda.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/orchestration-providers/webhook/scale-down-state-diagram.md:mkdocs_scale_down_state_diagram" @@ -69,20 +69,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 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | | [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | @@ -91,7 +91,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 | @@ -104,23 +104,23 @@ 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.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 runner-configuration 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 configuration 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`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the 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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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({
artifact = optional(object({
s3 = optional(object({
bucket = 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), {})
principals = optional(list(object({
type = string
identifiers = list(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 | -| [orchestration](#input\_orchestration) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null.

`webhook` is the currently supported provider. It owns the build queue reference, scale-control
artifact selection, runner capacity limit, scale-up, scale-down, scheduled pool, and job-retry
controls. Wrapper presence selects the provider and must therefore be known during planning. Future
providers can be added as sibling blocks without moving the webhook contract again. |
object({
webhook = optional(object({
runner = optional(object({
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
scale = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
}), {})
scale_up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(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")
})), [])
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})
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)
}), {})
}), {})
}), null)
})
| n/a | yes | +| [orchestration](#input\_orchestration) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null.

`webhook` is the currently supported provider. It owns the build queue reference, the runner-control
artifact shared by scale, pool, and job-retry, runner lifecycle and capacity limits, scale-up, scale-down, and scheduled pool
controls. Wrapper presence selects the provider and must therefore be known during planning. Future
providers can be added as sibling blocks without moving the webhook contract again. |
object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})
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)
}), {})
}), {})
}), null)
})
| n/a | yes | | [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no | -| [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.
- `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| 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`.
- `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.
- `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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")
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | | [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.

- `paths.root`: Root Parameter Store path for this runner configuration.
- `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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `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.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `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_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
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 runner configuration. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [orchestration](#output\_orchestration) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf index 9084b3a9dd..f7d8d7ab7d 100644 --- a/modules/runner-config/orchestration-provider.tf +++ b/modules/runner-config/orchestration-provider.tf @@ -9,6 +9,10 @@ locals { orchestration_provider_enabled = { webhook = local.orchestration_provider_type == "webhook" } + + orchestration_provider_runner_lifecycle = { + webhook = one([for provider in values(module.webhook) : provider.runner_lifecycle]) + }[local.orchestration_provider_type] } moved { diff --git a/modules/runner-config/runner-ssm-parameters.tf b/modules/runner-config/runner-ssm-parameters.tf index 43708f1d02..1d97c908a8 100644 --- a/modules/runner-config/runner-ssm-parameters.tf +++ b/modules/runner-config/runner-ssm-parameters.tf @@ -2,7 +2,7 @@ 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" + value = local.orchestration_provider_runner_lifecycle.ephemeral ? "ephemeral" : "persistent" tags = local.ssm_parameter_tags } @@ -16,7 +16,7 @@ resource "aws_ssm_parameter" "disable_default_labels" { 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 + value = local.orchestration_provider_runner_lifecycle.jit_config_enabled tags = local.ssm_parameter_tags } diff --git a/modules/runner-config/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper.tf index 6ed3aff418..5bb31bb7b5 100644 --- a/modules/runner-config/ssm-housekeeper.tf +++ b/modules/runner-config/ssm-housekeeper.tf @@ -23,7 +23,7 @@ module "ssm_housekeeper" { } lambda = { # The housekeeper resolves only its component-owned selector and never - # inherits the selected orchestration provider's scale artifact. + # inherits the selected orchestration provider's runner-control artifact. artifact = local.ssm_housekeeper_artifact runtime = var.lambda.runtime architecture = var.lambda.architecture diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md index 3bbf0f9027..7511cca2e0 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md @@ -2,27 +2,27 @@ ## 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 | @@ -33,7 +33,7 @@ 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 | diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index 37164791fc..dc262b3d1d 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -88,11 +88,9 @@ module "external_iam" { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/build-queue-${random_id.external.hex}" } lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" - } + artifact = { + s3 = { + key = "runners.zip" } } pool = { @@ -185,11 +183,9 @@ module "generated_policy" { } } lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" - } + artifact = { + s3 = { + key = "runners.zip" } } } diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl index 23621e9d87..58667f3cfe 100644 --- a/modules/runner-config/tests/pool.tftest.hcl +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -88,7 +88,10 @@ variables { orchestration = { webhook = { runner = { - maximum_count = 9 + boot_time_in_minutes = 8 + ephemeral = true + jit_config_enabled = null + maximum_count = 9 } github = { organization_runners = true @@ -100,11 +103,9 @@ variables { } } lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" - } + artifact = { + s3 = { + key = "runners.zip" } } pool = { @@ -138,11 +139,27 @@ run "plan_with_pool_enabled" { assert { condition = ( !contains(keys(var.runner), "maximum_count") + && !contains(keys(var.runner), "boot_time_in_minutes") + && !contains(keys(var.runner), "ephemeral") + && !contains(keys(var.runner), "jit_config_enabled") + && var.orchestration.webhook.runner.boot_time_in_minutes == 8 + && var.orchestration.webhook.runner.ephemeral + && var.orchestration.webhook.runner.jit_config_enabled == null && var.orchestration.webhook.runner.maximum_count == 9 && module.webhook["webhook"].scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + && module.webhook["webhook"].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" && module.webhook["webhook"].pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + && module.webhook["webhook"].pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + ) + error_message = "Runner capacity and boot time must be owned by orchestration.webhook.runner and routed to webhook controls, not retained in the common runner contract." + } + + assert { + condition = ( + aws_ssm_parameter.runner_agent_mode.value == "ephemeral" + && aws_ssm_parameter.jit_config_enabled.value == "true" ) - error_message = "Runner capacity must be owned by orchestration.webhook.runner and routed to webhook scale-up and pool, not retained in the common runner contract." + error_message = "Runner-config must serialize the webhook provider's resolved lifecycle contract without duplicating its JIT fallback." } assert { @@ -239,8 +256,8 @@ run "plan_with_pool_enabled" { } assert { - condition = module.webhook["webhook"].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" - error_message = "Scale-down must merge the EC2 environment fragment." + condition = module.webhook["webhook"].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + error_message = "Scale-down must receive boot time from the webhook orchestration configuration." } assert { @@ -556,11 +573,9 @@ run "job_retry_uses_common_runner_configuration_identity" { } } lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" - } + artifact = { + s3 = { + key = "runners.zip" } } } diff --git a/modules/runner-config/tests/tags.tftest.hcl b/modules/runner-config/tests/tags.tftest.hcl index f627751df8..c620ba12ef 100644 --- a/modules/runner-config/tests/tags.tftest.hcl +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -105,23 +105,23 @@ variables { } } lambda = { - scale = { - artifact = { - s3 = { - key = "runners.zip" - } + artifact = { + s3 = { + key = "runners.zip" } } - scale_up = { - tags = { - precedence = "scale-up" - scale_up = "yes" + scale = { + up = { + tags = { + precedence = "scale-up" + scale_up = "yes" + } } - } - scale_down = { - tags = { - precedence = "scale-down" - scale_down = "yes" + down = { + tags = { + precedence = "scale-down" + scale_down = "yes" + } } } pool = { diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf index 348377b331..d17d5e6b97 100644 --- a/modules/runner-config/variables.orchestration-provider.tf +++ b/modules/runner-config/variables.orchestration-provider.tf @@ -3,15 +3,18 @@ variable "orchestration" { description = <<-EOT Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. - `webhook` is the currently supported provider. It owns the build queue reference, scale-control - artifact selection, runner capacity limit, scale-up, scale-down, scheduled pool, and job-retry + `webhook` is the currently supported provider. It owns the build queue reference, the runner-control + artifact shared by scale, pool, and job-retry, runner lifecycle and capacity limits, scale-up, scale-down, and scheduled pool controls. Wrapper presence selects the provider and must therefore be known during planning. Future providers can be added as sibling blocks without moving the webhook contract again. EOT type = object({ webhook = optional(object({ runner = optional(object({ - maximum_count = optional(number, 3) + boot_time_in_minutes = optional(number, 5) + ephemeral = optional(bool, false) + jit_config_enabled = optional(bool, null) + maximum_count = optional(number, 3) }), {}) github = object({ organization_runners = bool @@ -25,38 +28,38 @@ variable "orchestration" { tags = optional(map(string), {}) }) lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) scale = optional(object({ - artifact = optional(object({ - zip = optional(string, null) - s3 = optional(object({ - key = string - object_version = optional(string, null) - }), null) + up = optional(object({ + memory_size = optional(number, 512) + timeout = optional(number, 60) + reserved_concurrent_executions = optional(number, 1) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, 10) + maximum_batching_window_in_seconds = optional(number, 0) + }), {}) + tags = optional(map(string), {}) }), {}) - }), {}) - scale_up = optional(object({ - memory_size = optional(number, 512) - timeout = optional(number, 60) - reserved_concurrent_executions = optional(number, 1) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, 10) - maximum_batching_window_in_seconds = optional(number, 0) + down = optional(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") + })), []) }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(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") - })), []) }), {}) pool = optional(object({ memory_size = optional(number, 512) @@ -98,20 +101,20 @@ variable "orchestration" { validation { condition = var.orchestration.webhook == null ? true : ( - var.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size >= 1 && - var.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size <= 1000 && - var.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds >= 0 && - var.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds <= 300 + var.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size >= 1 && + var.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size <= 1000 && + var.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds >= 0 && + var.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds <= 300 ) - error_message = "orchestration.webhook.lambda.scale_up.event_source_mapping batch size must be between 1 and 1000 and its batching window between 0 and 300 seconds." + error_message = "orchestration.webhook.lambda.scale.up.event_source_mapping batch size must be between 1 and 1000 and its batching window between 0 and 300 seconds." } validation { condition = var.orchestration.webhook == null ? true : !( - var.orchestration.webhook.lambda.scale.artifact.zip != null && - var.orchestration.webhook.lambda.scale.artifact.s3 != null + var.orchestration.webhook.lambda.artifact.zip != null && + var.orchestration.webhook.lambda.artifact.s3 != null ) - error_message = "orchestration.webhook.lambda.scale.artifact must select at most one of zip or s3." + error_message = "orchestration.webhook.lambda.artifact must select at most one of zip or s3." } validation { diff --git a/modules/runner-config/variables.tf b/modules/runner-config/variables.tf index 1f82064979..f2d2cba5ab 100644 --- a/modules/runner-config/variables.tf +++ b/modules/runner-config/variables.tf @@ -27,15 +27,12 @@ variable "runner" { - `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. - - `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. @@ -49,15 +46,12 @@ variable "runner" { type = 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") - ephemeral = optional(bool, false) - jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, false) tags = optional(map(string), {}) hooks = optional(object({ From 7b3327899024cad00132c0b619e04639efa91331 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:49:32 +0000 Subject: [PATCH 25/49] docs: auto update terraform docs --- modules/compute-providers/ec2/README.md | 12 ++++++------ modules/multi-runner/README.md | 16 ++++++++-------- .../fixtures/computed-runner-inputs/README.md | 10 +++++----- .../orchestration-providers/webhook/README.md | 8 ++++---- .../webhook/pool/README.md | 12 ++++++------ .../webhook/scale-runners/README.md | 12 ++++++------ modules/runner-config/README.md | 14 +++++++------- .../tests/fixtures/computed-iam-inputs/README.md | 10 +++++----- 8 files changed, 47 insertions(+), 47 deletions(-) diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md index c8a09180b9..1963eea11f 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. The parent runner configuration selects ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | ## Modules @@ -28,7 +28,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 | @@ -58,7 +58,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 by compute-provider resources and policy documents. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 | @@ -72,7 +72,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | | [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index c2cebe0379..ed02883f89 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,15 +167,15 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | ## 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 | @@ -187,7 +187,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 | @@ -200,7 +200,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -285,7 +285,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/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 69235e419a..5e0e532bc0 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,26 +2,26 @@ ## 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 | -| ---- | ------ | ------- | +|------|--------|---------| | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs @@ -31,7 +31,7 @@ No inputs. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | | [runner\_config\_keys](#output\_runner\_config\_keys) | n/a | diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index 4a38f8a2ee..c6c25f2afd 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -10,7 +10,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | @@ -21,7 +21,7 @@ No providers. ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | | [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | @@ -33,7 +33,7 @@ No resources. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [config](#input\_config) | Resolved provider-owned values from orchestration.webhook, including runner lifecycle, boot timeout, and capacity limits used by scaling and pool controls. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | | [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | @@ -48,7 +48,7 @@ No resources. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry](#output\_job\_retry) | Job-retry resources. Null when job retry is disabled. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool schedule is configured. | | [runner\_lifecycle](#output\_runner\_lifecycle) | Effective webhook-owned runner lifecycle consumed by runner-config bootstrap parameters. | diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index a7be3fa08b..1cf4a1a545 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/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.60.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 | @@ -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 webhook orchestration provider 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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
boot_time_in_minutes = number
})
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_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = 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,6 +59,6 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index ef156e5808..bcb6065b25 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -10,15 +10,15 @@ The module is an implementation detail of the experimental runner configuration. ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | +|------|---------| +| [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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume 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.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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
boot_time_in_minutes = number
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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = 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
})
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-config/README.md b/modules/runner-config/README.md index faf9c70118..e63dddde08 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -69,20 +69,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.60.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | | [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | @@ -91,7 +91,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 | @@ -104,7 +104,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.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 runner-configuration 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 configuration 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 | -| ---- | ----------- | +|------|-------------| | [orchestration](#output\_orchestration) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md index 7511cca2e0..3bbf0f9027 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md @@ -2,27 +2,27 @@ ## 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 | @@ -33,7 +33,7 @@ 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 | From e621653e7966e1ecd78a20cd01d4bc52f8004b9c Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 22:11:19 +0200 Subject: [PATCH 26/49] refactor(runner-config): clarify provider module addresses --- ...-runner-orchestration-provider-boundary.md | 9 ++++-- .../internal/compute-provider-refactor.md | 2 +- modules/runner-config/README.md | 4 +-- modules/runner-config/compute-provider.tf | 2 +- modules/runner-config/ec2.tf | 7 +++- .../runner-config/orchestration-provider.tf | 16 ++++++---- modules/runner-config/outputs.tf | 14 ++++---- modules/runner-config/tests/pool.tftest.hcl | 32 +++++++++---------- modules/runner-config/tests/tags.tftest.hcl | 30 ++++++++--------- 9 files changed, 63 insertions(+), 53 deletions(-) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index 32742d30e5..b47db5d29a 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -169,9 +169,12 @@ Stable inputs are translated into the same internal canonical representation so The experimental implementation preserves in-progress v2 state with declarative moves: - `module.runner_stacks` moves to `module.runner_configs`; -- scale-up/scale-down resources move beneath `module.webhook["webhook"].module.scale_runners`; -- pool resources move beneath `module.webhook["webhook"].module.pool`; and -- job-retry resources move beneath `module.webhook["webhook"].module.job_retry`. +- EC2 compute resources move from `module.ec2[0]` to `module.compute_ec2[0]`; +- scale-up/scale-down resources move beneath `module.orchestration_webhook[0].module.scale_runners`; +- pool resources move beneath `module.orchestration_webhook[0].module.pool`; and +- job-retry resources move beneath `module.orchestration_webhook[0].module.job_retry`. + +The runner configuration uses one explicitly named, count-addressed module per concrete provider. Compute modules follow `module.compute_[0]`, while orchestration modules follow `module.orchestration_[0]`. This avoids duplicating the provider name in addresses such as `module.webhook["webhook"]` and gives future providers symmetric addresses such as `module.compute_microvm[0]` and `module.orchestration_scale_set[0]`. Chained moves from the previously published module addresses preserve existing experimental state. The canonical v2 output groups resources under `orchestration.webhook`. Direct `scale_up`, `scale_down`, and `pool` outputs remain compatibility aliases during the experimental transition. diff --git a/docs/modules/internal/compute-provider-refactor.md b/docs/modules/internal/compute-provider-refactor.md index b93015541d..2723f0f57d 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -101,7 +101,7 @@ The canonical object gives shared singleton resources one global representation - The v1 translation wraps its existing registration scope, matcher, queue, scale, pool, and retry values under `orchestration.webhook`; the stable public input and resource behavior remain unchanged. - Stable queue tagging and the flat `runners_map` output remain unchanged. - When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-config` at `module.runner_configs["configuration"]`; stable-map entries are not dispatched. -- Declarative `moved` blocks preserve the experimental call rename from `module.runner_stacks` to `module.runner_configs` and move the former runner-config scale, pool, and retry children directly beneath `module.webhook["webhook"]` without an intermediate state address. +- Declarative `moved` blocks preserve historical runner-config and provider-child addresses, then converge compute and orchestration resources beneath `module.compute_ec2[0]` and `module.orchestration_webhook[0]`. - Experimental resources are exposed separately through the nested `runners_map_v2` output. - The maps are not combined. A non-empty v2 map has explicit priority over the stable map. diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index e63dddde08..34f0f8c1ba 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -83,10 +83,10 @@ yarn run dist | Name | Source | Version | |------|--------|---------| -| [ec2](#module\_ec2) | ../compute-providers/ec2 | n/a | +| [compute\_ec2](#module\_compute\_ec2) | ../compute-providers/ec2 | n/a | | [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | +| [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | | [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | -| [webhook](#module\_webhook) | ../orchestration-providers/webhook | n/a | ## Resources diff --git a/modules/runner-config/compute-provider.tf b/modules/runner-config/compute-provider.tf index 78e00bf03c..9549c2294a 100644 --- a/modules/runner-config/compute-provider.tf +++ b/modules/runner-config/compute-provider.tf @@ -11,7 +11,7 @@ locals { provider_assume_role_policy = local.provider_assume_role_policies[local.provider_type] provider_contracts = { - ec2 = one(module.ec2[*].provider) + ec2 = one(module.compute_ec2[*].provider) } provider_contract = local.provider_contracts[local.provider_type] diff --git a/modules/runner-config/ec2.tf b/modules/runner-config/ec2.tf index 071174f859..93fb710ade 100644 --- a/modules/runner-config/ec2.tf +++ b/modules/runner-config/ec2.tf @@ -5,7 +5,12 @@ module "ec2_trust_policy" { additional_trust_policy_json = var.runner.iam.additional_trust_policy_json } -module "ec2" { +moved { + from = module.ec2[0] + to = module.compute_ec2[0] +} + +module "compute_ec2" { count = local.provider_type == "ec2" ? 1 : 0 source = "../compute-providers/ec2" diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf index f7d8d7ab7d..fa25a66d21 100644 --- a/modules/runner-config/orchestration-provider.tf +++ b/modules/runner-config/orchestration-provider.tf @@ -11,7 +11,7 @@ locals { } orchestration_provider_runner_lifecycle = { - webhook = one([for provider in values(module.webhook) : provider.runner_lifecycle]) + webhook = one(module.orchestration_webhook[*].runner_lifecycle) }[local.orchestration_provider_type] } @@ -30,18 +30,20 @@ moved { to = module.webhook["webhook"].module.job_retry } -module "webhook" { +moved { + from = module.webhook["webhook"] + to = module.orchestration_webhook[0] +} + +module "orchestration_webhook" { source = "../orchestration-providers/webhook" - for_each = { - for provider_type, provider_config in local.orchestration_providers : provider_type => provider_config - if provider_type == "webhook" - } + count = local.orchestration_provider_enabled.webhook ? 1 : 0 aws_partition = var.aws_partition prefix = var.prefix tags = var.tags - config = each.value + config = var.orchestration.webhook runner = var.runner github = var.github lambda = { diff --git a/modules/runner-config/outputs.tf b/modules/runner-config/outputs.tf index 6a2ec80e86..8c833eab95 100644 --- a/modules/runner-config/outputs.tf +++ b/modules/runner-config/outputs.tf @@ -7,27 +7,27 @@ output "runner" { output "scale_up" { description = "Scale-up control-plane resources. Null when webhook orchestration is not configured." - value = one([for provider in values(module.webhook) : provider.scale_up]) + value = one(module.orchestration_webhook[*].scale_up) } output "scale_down" { description = "Scale-down control-plane resources. Null when webhook orchestration is not configured." - value = one([for provider in values(module.webhook) : provider.scale_down]) + value = one(module.orchestration_webhook[*].scale_down) } output "pool" { description = "Scheduled pool resources. Null when no pool configuration is supplied." - value = one([for provider in values(module.webhook) : provider.pool]) + value = one(module.orchestration_webhook[*].pool) } output "orchestration" { description = "Resources grouped under the selected runner orchestration provider." value = { webhook = local.orchestration_provider_enabled.webhook ? { - scale_up = one([for provider in values(module.webhook) : provider.scale_up]) - scale_down = one([for provider in values(module.webhook) : provider.scale_down]) - pool = one([for provider in values(module.webhook) : provider.pool]) - job_retry = one([for provider in values(module.webhook) : provider.job_retry]) + scale_up = one(module.orchestration_webhook[*].scale_up) + scale_down = one(module.orchestration_webhook[*].scale_down) + pool = one(module.orchestration_webhook[*].pool) + job_retry = one(module.orchestration_webhook[*].job_retry) } : null } } diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl index 58667f3cfe..7828cc6d11 100644 --- a/modules/runner-config/tests/pool.tftest.hcl +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -132,7 +132,7 @@ run "plan_with_pool_enabled" { command = plan assert { - condition = module.webhook["webhook"].pool != null + condition = module.orchestration_webhook[0].pool != null error_message = "Pool module should be enabled when pool.config is non-empty" } @@ -146,10 +146,10 @@ run "plan_with_pool_enabled" { && var.orchestration.webhook.runner.ephemeral && var.orchestration.webhook.runner.jit_config_enabled == null && var.orchestration.webhook.runner.maximum_count == 9 - && module.webhook["webhook"].scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" - && module.webhook["webhook"].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" - && module.webhook["webhook"].pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" - && module.webhook["webhook"].pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + && module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + && module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + && module.orchestration_webhook[0].pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + && module.orchestration_webhook[0].pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" ) error_message = "Runner capacity and boot time must be owned by orchestration.webhook.runner and routed to webhook controls, not retained in the common runner contract." } @@ -164,8 +164,8 @@ run "plan_with_pool_enabled" { assert { condition = ( - module.webhook["webhook"].scale_up.lambda.s3_bucket == "my-lambda-bucket" - && module.webhook["webhook"].scale_up.lambda.s3_key == "runners.zip" + module.orchestration_webhook[0].scale_up.lambda.s3_bucket == "my-lambda-bucket" + && module.orchestration_webhook[0].scale_up.lambda.s3_key == "runners.zip" && local.ssm_housekeeper_artifact.s3.bucket == null && endswith(local.ssm_housekeeper_artifact.zip, "/lambdas/functions/control-plane/runners.zip") ) @@ -215,7 +215,7 @@ run "plan_with_pool_enabled" { } assert { - condition = length(jsondecode(module.webhook["webhook"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 + condition = length(jsondecode(module.orchestration_webhook[0].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." } @@ -244,26 +244,26 @@ run "plan_with_pool_enabled" { assert { condition = ( - module.webhook["webhook"].scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" - && module.webhook["webhook"].scale_down.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + && module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" ) error_message = "Scaling Lambdas must receive the provider type from the selected provider." } assert { - condition = module.webhook["webhook"].scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + condition = module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" error_message = "Scale-up must merge the EC2 environment fragment." } assert { - condition = module.webhook["webhook"].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + condition = module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" error_message = "Scale-down must receive boot time from the webhook orchestration configuration." } assert { condition = ( - toset(keys(module.webhook["webhook"].scale_up)) == toset(["lambda", "log_group", "role"]) - && toset(keys(module.webhook["webhook"].scale_down)) == toset(["lambda", "log_group", "role"]) + toset(keys(module.orchestration_webhook[0].scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(module.orchestration_webhook[0].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." } @@ -590,12 +590,12 @@ run "job_retry_uses_common_runner_configuration_identity" { } assert { - condition = module.webhook["webhook"].job_retry.lambda.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" + condition = module.orchestration_webhook[0].job_retry.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.webhook["webhook"].job_retry.lambda.function.reserved_concurrent_executions == 2 + condition = module.orchestration_webhook[0].job_retry.lambda.function.reserved_concurrent_executions == 2 error_message = "Job retry must apply its configured Lambda reserved concurrency." } } diff --git a/modules/runner-config/tests/tags.tftest.hcl b/modules/runner-config/tests/tags.tftest.hcl index c620ba12ef..27350d15b1 100644 --- a/modules/runner-config/tests/tags.tftest.hcl +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -184,22 +184,22 @@ run "layered_component_tags" { command = plan assert { - condition = module.webhook["webhook"].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" + condition = module.orchestration_webhook[0].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 = module.webhook["webhook"].scale_up.lambda.tags == tomap({ + condition = module.orchestration_webhook[0].scale_up.lambda.tags == tomap({ precedence = "scale-up" module = "yes" lambda = "yes" scale_up = "yes" - }) && module.webhook["webhook"].scale_up.log_group.tags == tomap({ + }) && module.orchestration_webhook[0].scale_up.log_group.tags == tomap({ precedence = "scale-up" module = "yes" log = "yes" scale_up = "yes" - }) && module.webhook["webhook"].scale_up.role.tags == tomap({ + }) && module.orchestration_webhook[0].scale_up.role.tags == tomap({ precedence = "scale-up" module = "yes" scale_up = "yes" @@ -208,17 +208,17 @@ run "layered_component_tags" { } assert { - condition = module.webhook["webhook"].scale_down.lambda.tags == tomap({ + condition = module.orchestration_webhook[0].scale_down.lambda.tags == tomap({ precedence = "scale-down" module = "yes" lambda = "yes" scale_down = "yes" - }) && module.webhook["webhook"].scale_down.log_group.tags == tomap({ + }) && module.orchestration_webhook[0].scale_down.log_group.tags == tomap({ precedence = "scale-down" module = "yes" log = "yes" scale_down = "yes" - }) && module.webhook["webhook"].scale_down.role.tags == tomap({ + }) && module.orchestration_webhook[0].scale_down.role.tags == tomap({ precedence = "scale-down" module = "yes" scale_down = "yes" @@ -242,7 +242,7 @@ run "layered_component_tags" { ssm = "yes" parameter = "yes" }) && tomap({ - for tag in jsondecode(module.webhook["webhook"].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + for tag in jsondecode(module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ precedence = "ssm-parameter" @@ -276,17 +276,17 @@ run "layered_component_tags" { } assert { - condition = module.webhook["webhook"].pool.lambda.tags == tomap({ + condition = module.orchestration_webhook[0].pool.lambda.tags == tomap({ precedence = "pool" module = "yes" lambda = "yes" pool = "yes" - }) && module.webhook["webhook"].pool.log_group.tags == tomap({ + }) && module.orchestration_webhook[0].pool.log_group.tags == tomap({ precedence = "pool" module = "yes" log = "yes" pool = "yes" - }) && module.webhook["webhook"].pool.role.tags == tomap({ + }) && module.orchestration_webhook[0].pool.role.tags == tomap({ precedence = "pool" module = "yes" pool = "yes" @@ -295,21 +295,21 @@ run "layered_component_tags" { } assert { - condition = module.webhook["webhook"].job_retry.lambda.function.tags == tomap({ + condition = module.orchestration_webhook[0].job_retry.lambda.function.tags == tomap({ precedence = "job-retry" module = "yes" lambda = "yes" job_retry = "yes" - }) && module.webhook["webhook"].job_retry.lambda.log_group.tags == tomap({ + }) && module.orchestration_webhook[0].job_retry.lambda.log_group.tags == tomap({ precedence = "job-retry" module = "yes" log = "yes" job_retry = "yes" - }) && module.webhook["webhook"].job_retry.lambda.role.tags == tomap({ + }) && module.orchestration_webhook[0].job_retry.lambda.role.tags == tomap({ precedence = "job-retry" module = "yes" job_retry = "yes" - }) && module.webhook["webhook"].job_retry.queue.tags == tomap({ + }) && module.orchestration_webhook[0].job_retry.queue.tags == tomap({ precedence = "job-retry" module = "yes" queue = "yes" From 6ff0f84fafc4abc4ecc2e1cff9c7ff84a61bbd5b Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 22:19:22 +0200 Subject: [PATCH 27/49] docs(orchestration): document provider configuration --- .../orchestration-providers/webhook/README.md | 4 +- .../webhook/variables.tf | 57 +++++++++++++++++- modules/runner-config/README.md | 2 +- .../variables.orchestration-provider.tf | 59 +++++++++++++++++-- 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index c6c25f2afd..253830030e 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -35,13 +35,13 @@ No resources. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | -| [config](#input\_config) | Resolved provider-owned values from orchestration.webhook, including runner lifecycle, boot timeout, and capacity limits used by scaling and pool controls. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | +| [config](#input\_config) | Provider-owned webhook values supplied from `orchestration.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
- `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 applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | | [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection. |
object({
artifact = object({
s3 = object({
bucket = optional(string, null)
})
})
runtime = string
architecture = string
subnet_ids = list(string)
security_group_ids = list(string)
tags = optional(map(string), {})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
| n/a | yes | | [observability](#input\_observability) | Common logging, tracing, and metrics configuration consumed by webhook controls. |
object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
tags = optional(map(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
})
})
})
| n/a | yes | | [prefix](#input\_prefix) | Prefix used to identify resources created for this webhook orchestration provider. | `string` | n/a | yes | | [runner](#input\_runner) | Common runner registration values consumed by webhook demand controls. Lifecycle, boot timeout, and capacity remain provider-owned under config.runner. |
object({
os = string
auto_update_disabled = bool
labels = list(string)
group_name = string
name_prefix = string
})
| n/a | yes | -| [runner\_provider](#input\_runner\_provider) | Selected compute-provider capabilities consumed by webhook scaling, pool, and retry controls. |
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
})
pool = object({
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
})
| n/a | yes | +| [runner\_provider](#input\_runner\_provider) | Selected compute-provider capabilities consumed by webhook scale-up, scale-down, and pool controls. |
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
})
pool = object({
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
})
| n/a | yes | | [ssm](#input\_ssm) | Resolved Parameter Store paths, optional decrypt key, and runtime parameter tags. |
object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
kms_key_id = optional(string, null)
parameter_store_tags = string
})
| n/a | yes | | [tags](#input\_tags) | Base tags available to webhook-provider resources. Component-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf index dcb6f0b0d8..50f3baf2d3 100644 --- a/modules/orchestration-providers/webhook/variables.tf +++ b/modules/orchestration-providers/webhook/variables.tf @@ -16,7 +16,60 @@ variable "tags" { } variable "config" { - description = "Resolved provider-owned values from orchestration.webhook, including runner lifecycle, boot timeout, and capacity limits used by scaling and pool controls." + description = <<-EOT + Provider-owned webhook values supplied from `orchestration.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks. + + - `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration. + - `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. + - `runner.ephemeral`: Registers runners in ephemeral mode. + - `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`. + - `runner.maximum_count`: Maximum number of runners managed for this runner configuration. + - `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped. + - `queue.build.arn`: ARN of the runner configuration's build queue. + - `queue.build.url`: URL of the runner configuration's build queue. + - `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key. + - `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. + - `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive. + - `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. + - `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket. + - `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive. + - `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. + - `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. + - `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. + - `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency. + - `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode. + - `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. + - `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. + - `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. + - `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. + - `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. + - `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. + - `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default. + - `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. + - `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. + - `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period. + - `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. + - `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. + - `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. + - `lambda.pool.timeout`: Pool Lambda timeout in seconds. + - `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. + - `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component. + - `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `lambda.pool.config[].size`: Desired number of runners for the schedule. + - `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. + - `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. + - `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. + - `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 applied within job-retry resource scopes after common provider tags. + - `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. + - `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency. + - `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. + EOT type = object({ runner = object({ boot_time_in_minutes = number @@ -197,7 +250,7 @@ variable "observability" { } variable "runner_provider" { - description = "Selected compute-provider capabilities consumed by webhook scaling, pool, and retry controls." + description = "Selected compute-provider capabilities consumed by webhook scale-up, scale-down, and pool controls." type = object({ type = string scale_up = object({ diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index 34f0f8c1ba..e5711ad414 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -111,7 +111,7 @@ yarn run dist | [github](#input\_github) | GitHub API and runner-registration configuration.

- `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the 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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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({
artifact = optional(object({
s3 = optional(object({
bucket = 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), {})
principals = optional(list(object({
type = string
identifiers = list(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 | -| [orchestration](#input\_orchestration) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null.

`webhook` is the currently supported provider. It owns the build queue reference, the runner-control
artifact shared by scale, pool, and job-retry, runner lifecycle and capacity limits, scale-up, scale-down, and scheduled pool
controls. Wrapper presence selects the provider and must therefore be known during planning. Future
providers can be added as sibling blocks without moving the webhook contract again. |
object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})
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)
}), {})
}), {})
}), null)
})
| n/a | yes | +| [orchestration](#input\_orchestration) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.

- `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
- `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`.
- `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`.
- `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `webhook.queue.build.arn`: ARN of the runner configuration's build queue.
- `webhook.queue.build.url`: URL of the runner configuration's build queue.
- `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key.
- `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`.
- `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive.
- `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null.
- `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null.
- `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null.
- `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`.
- `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`.
- `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`.
- `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`.
- `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`.
- `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`.
- `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`.
- `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`.
- `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`.
- `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`.
- `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. |
object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})
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)
}), {})
}), {})
}), null)
})
| n/a | yes | | [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no | | [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`.
- `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.
- `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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")
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | | [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.

- `paths.root`: Root Parameter Store path for this runner configuration.
- `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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `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.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `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_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
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 | diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf index d17d5e6b97..eec91e9491 100644 --- a/modules/runner-config/variables.orchestration-provider.tf +++ b/modules/runner-config/variables.orchestration-provider.tf @@ -1,12 +1,59 @@ # Typed orchestration-provider input boundary between the common runner configuration and demand controllers. variable "orchestration" { description = <<-EOT - Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. + Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply. - `webhook` is the currently supported provider. It owns the build queue reference, the runner-control - artifact shared by scale, pool, and job-retry, runner lifecycle and capacity limits, scale-up, scale-down, and scheduled pool - controls. Wrapper presence selects the provider and must therefore be known during planning. Future - providers can be added as sibling blocks without moving the webhook contract again. + - `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract. + - `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration. + - `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`. + - `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`. + - `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`. + - `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`. + - `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped. + - `webhook.queue.build.arn`: ARN of the runner configuration's build queue. + - `webhook.queue.build.url`: URL of the runner configuration's build queue. + - `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key. + - `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`. + - `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive. + - `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null. + - `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null. + - `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive. + - `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null. + - `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`. + - `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`. + - `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. + - `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. + - `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. + - `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`. + - `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`. + - `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`. + - `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. + - `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`. + - `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. + - `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`. + - `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. + - `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period. + - `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. + - `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`. + - `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`. + - `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`. + - `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency. + - `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component. + - `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule. + - `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`. + - `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null. + - `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`. + - `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`. + - `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`. + - `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`. + - `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`. + - `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`. + - `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`. + - `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency. + - `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. EOT type = object({ webhook = optional(object({ @@ -52,13 +99,13 @@ variable "orchestration" { 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") })), []) + tags = optional(map(string), {}) }), {}) }), {}) pool = optional(object({ From 28113436ab3fca94abbd71017ead970ab9cfe3b9 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 22:32:16 +0200 Subject: [PATCH 28/49] refactor(state): remove legacy module moves --- modules/multi-runner/runners.experimental.tf | 5 ----- modules/runner-config/ec2.tf | 5 ----- .../runner-config/orchestration-provider.tf | 20 ------------------- 3 files changed, 30 deletions(-) diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index b8481c54e1..4ddaae2dd4 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -1,8 +1,3 @@ -moved { - from = module.runner_stacks - to = module.runner_configs -} - module "runner_configs" { source = "../runner-config" for_each = { diff --git a/modules/runner-config/ec2.tf b/modules/runner-config/ec2.tf index 93fb710ade..dc1016a1a1 100644 --- a/modules/runner-config/ec2.tf +++ b/modules/runner-config/ec2.tf @@ -5,11 +5,6 @@ module "ec2_trust_policy" { additional_trust_policy_json = var.runner.iam.additional_trust_policy_json } -moved { - from = module.ec2[0] - to = module.compute_ec2[0] -} - module "compute_ec2" { count = local.provider_type == "ec2" ? 1 : 0 source = "../compute-providers/ec2" diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf index fa25a66d21..02af4820da 100644 --- a/modules/runner-config/orchestration-provider.tf +++ b/modules/runner-config/orchestration-provider.tf @@ -15,26 +15,6 @@ locals { }[local.orchestration_provider_type] } -moved { - from = module.scale_runners - to = module.webhook["webhook"].module.scale_runners -} - -moved { - from = module.pool - to = module.webhook["webhook"].module.pool -} - -moved { - from = module.job_retry - to = module.webhook["webhook"].module.job_retry -} - -moved { - from = module.webhook["webhook"] - to = module.orchestration_webhook[0] -} - module "orchestration_webhook" { source = "../orchestration-providers/webhook" count = local.orchestration_provider_enabled.webhook ? 1 : 0 From 406304378b0ba4aa944e51de7ddef737127260c6 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 23:11:05 +0200 Subject: [PATCH 29/49] refactor(orchestration): align provider boundaries Use orchestration_provider consistently with compute_provider, centralize nested-module validation with terraform_data preconditions, and split stable-v1 from experimental-v2 routing tests. --- ...-runner-orchestration-provider-boundary.md | 39 +- docs/index.md | 12 +- .../internal/compute-provider-refactor.md | 38 +- .../ec2/trust-policy/README.md | 2 + .../tests/trust-policy.tftest.hcl | 6 +- .../ec2/trust-policy/validations.tf | 8 + .../ec2/trust-policy/variables.tf | 5 - modules/multi-runner/README.md | 22 +- .../config.experimental.translation.tf | 108 +- modules/multi-runner/outputs.tf | 18 +- modules/multi-runner/queues.tf | 32 +- modules/multi-runner/runners.experimental.tf | 14 +- modules/multi-runner/runners.tf | 66 +- .../fixtures/computed-runner-inputs/main.tf | 4 +- .../tests/provider-routing-v1.tftest.hcl | 815 +++++++++++ ...est.hcl => provider-routing-v2.tftest.hcl} | 1230 +++-------------- .../multi-runner/validations.experimental.tf | 84 +- .../multi-runner/variables.experimental.tf | 252 ++-- modules/multi-runner/webhook.tf | 28 +- .../orchestration-providers/webhook/README.md | 14 +- .../webhook/job-retry/README.md | 4 +- .../job-retry/tests/job-retry.tftest.hcl | 54 + .../webhook/job-retry/validations.tf | 26 + .../webhook/job-retry/variables.tf | 23 - .../webhook/job-retry/versions.tf | 2 +- .../webhook/pool/README.md | 4 +- .../webhook/pool/tests/provider.tftest.hcl | 49 + .../webhook/pool/validations.tf | 18 + .../webhook/pool/variables.tf | 15 - .../webhook/pool/versions.tf | 2 +- .../webhook/scale-down-state-diagram.md | 2 +- .../webhook/scale-runners/README.md | 4 +- .../tests/scale-runners.tftest.hcl | 19 + .../webhook/scale-runners/validations.tf | 8 + .../webhook/scale-runners/variables.tf | 5 - .../webhook/scale-runners/versions.tf | 2 +- .../webhook/tests/webhook.tftest.hcl | 20 + .../webhook/validations.tf | 11 + .../webhook/variables.tf | 10 +- .../webhook/versions.tf | 2 +- modules/runner-config/README.md | 20 +- modules/runner-config/common-config.tf | 12 +- modules/runner-config/compute-provider.tf | 2 +- modules/runner-config/ec2.tf | 2 +- .../runner-config/orchestration-provider.tf | 4 +- modules/runner-config/outputs.tf | 2 +- .../computed-iam-inputs.tf | 4 +- modules/runner-config/tests/pool.tftest.hcl | 72 +- modules/runner-config/tests/tags.tftest.hcl | 2 +- modules/runner-config/validations.tf | 111 ++ .../variables.compute-provider.tf | 7 - .../variables.orchestration-provider.tf | 32 +- modules/runner-config/variables.tf | 57 - modules/runner-config/versions.tf | 2 +- 54 files changed, 1825 insertions(+), 1581 deletions(-) create mode 100644 modules/compute-providers/ec2/trust-policy/validations.tf create mode 100644 modules/multi-runner/tests/provider-routing-v1.tftest.hcl rename modules/multi-runner/tests/{provider-routing.tftest.hcl => provider-routing-v2.tftest.hcl} (68%) create mode 100644 modules/orchestration-providers/webhook/job-retry/validations.tf create mode 100644 modules/orchestration-providers/webhook/pool/validations.tf create mode 100644 modules/orchestration-providers/webhook/scale-runners/validations.tf create mode 100644 modules/orchestration-providers/webhook/validations.tf create mode 100644 modules/runner-config/validations.tf diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index b47db5d29a..10d9617796 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -39,13 +39,13 @@ We will introduce a typed orchestration-provider boundary in the experimental mu ### Provider selection is per runner configuration -Every experimental runner configuration must contain an `orchestration` object with exactly one non-null typed provider block. In this phase the only supported block is `webhook`: +Every experimental runner configuration must contain an `orchestration_provider` object with exactly one non-null typed provider block. In this phase the only supported block is `webhook`: ```hcl experimental = { multi_runner_config = { linux_arm64 = { - orchestration = { + orchestration_provider = { webhook = { runner = { boot_time_in_minutes = 5 @@ -80,7 +80,7 @@ Validation counts non-null provider blocks rather than naming one special case. ### Global orchestration blocks provide defaults; they do not select providers -`experimental.orchestration.webhook` is the global defaults and shared-component namespace for webhook orchestration. Its presence does not select webhook orchestration for every runner configuration. Selection remains under `experimental.multi_runner_config..orchestration`. +`experimental.orchestration_provider.webhook` is the global defaults and shared-component namespace for webhook orchestration. Its presence does not select webhook orchestration for every runner configuration. Selection remains under `experimental.multi_runner_config..orchestration_provider`. The webhook global namespace owns: @@ -97,18 +97,18 @@ The webhook global namespace owns: Job-retry remains a per-runner-configuration webhook setting in this phase; its typed block supplies its own defaults rather than inheriting a global block. -Runner boot time, ephemeral mode, JIT configuration, and maximum runner count are webhook-provider settings rather than common runner identity. Their canonical paths live under `experimental.orchestration.webhook.runner`, with matching paths under `experimental.multi_runner_config..orchestration.webhook.runner` for runner-configuration overrides. Stable-v1 translation maps the existing lifecycle, boot-time, and capacity inputs into those provider paths, and the unchanged stable `modules/runners` call reads from the canonical provider block. No compatibility aliases are retained under the experimental common `runner` object. The webhook provider resolves a null JIT setting to the effective ephemeral mode, exposes that lifecycle contract to runner-config bootstrap, injects boot time into scale-down and pool, and keeps these settings out of compute-provider capabilities. +Runner boot time, ephemeral mode, JIT configuration, and maximum runner count are webhook-provider settings rather than common runner identity. Their canonical paths live under `experimental.orchestration_provider.webhook.runner`, with matching paths under `experimental.multi_runner_config..orchestration_provider.webhook.runner` for runner-configuration overrides. Stable-v1 translation maps the existing lifecycle, boot-time, and capacity inputs into those provider paths, and the unchanged stable `modules/runners` call reads from the canonical provider block. No compatibility aliases are retained under the experimental common `runner` object. The webhook provider resolves a null JIT setting to the effective ephemeral mode, exposes that lifecycle contract to runner-config bootstrap, injects boot time into scale-down and pool, and keeps these settings out of compute-provider capabilities. -The common `experimental.github` block continues to own credentials and GitHub API client settings shared across implementations, including `enterprise_server` and `user_agent`. Repository filtering belongs to the shared webhook at `experimental.orchestration.webhook.github.repository_white_list`; per-configuration `organization_runners` remains in the same provider-owned GitHub block. +The common `experimental.github` block continues to own credentials and GitHub API client settings shared across implementations, including `enterprise_server` and `user_agent`. Repository filtering belongs to the shared webhook at `experimental.orchestration_provider.webhook.github.repository_white_list`; per-configuration `organization_runners` remains in the same provider-owned GitHub block. The common `experimental.lambda` block contains only provider-neutral Lambda substrate: runtime, architecture, networking, role settings, additional principals, tags, and an optional shared artifact bucket. It does not select a provider archive. Each component owner supplies its own local zip or S3 object key and version. -The webhook provider owns one runner-control artifact at `orchestration.webhook.lambda.artifact`, shared by scale, pool, and job-retry. Its `lambda.scale` child contains only `up` and `down` configuration, while the ingress webhook retains its separate `lambda.webhook.artifact`. The provider-neutral SSM housekeeper owns its artifact selector under `ssm.housekeeper.lambda.artifact`. A runner-configuration selection overrides the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the common Lambda artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. This selector is independent of the webhook runner-control artifact. Stable-v1 translation maps the existing runner artifact into both canonical component contracts so the translated representation remains complete without changing the stable resource path. +The webhook provider owns one runner-control artifact at `orchestration_provider.webhook.lambda.artifact`, shared by scale, pool, and job-retry. Its `lambda.scale` child contains only `up` and `down` configuration, while the ingress webhook retains its separate `lambda.webhook.artifact`. The provider-neutral SSM housekeeper owns its artifact selector under `ssm.housekeeper.lambda.artifact`. A runner-configuration selection overrides the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the common Lambda artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. This selector is independent of the webhook runner-control artifact. Stable-v1 translation maps the existing runner artifact into both canonical component contracts so the translated representation remains complete without changing the stable resource path. For a selected webhook provider, resolution follows: ```text -runner-configuration override > experimental orchestration.webhook default +runner-configuration override > experimental.orchestration_provider.webhook default ``` Tag maps merge from broad to narrow. A runner-configuration override affects only that runner configuration; it does not configure a shared singleton. @@ -166,20 +166,20 @@ A future scale-set controller may require a different subset or extension of the Stable inputs are translated into the same internal canonical representation so defaults and shared singleton values have one resolution path. Stable runner configurations continue to call the existing `modules/runners` implementation at their existing addresses. Opting into experimental v2 is module-wide: a non-empty `experimental.multi_runner_config` replaces, rather than merges with, the stable map. -The experimental implementation preserves in-progress v2 state with declarative moves: +The runner configuration uses one explicitly named, count-addressed module per concrete provider. Compute modules follow `module.compute_[0]`, while orchestration modules follow `module.orchestration_[0]`. This avoids duplicating the provider name in addresses such as `module.webhook["webhook"]` and gives future providers symmetric addresses such as `module.compute_microvm[0]` and `module.orchestration_scale_set[0]`. -- `module.runner_stacks` moves to `module.runner_configs`; -- EC2 compute resources move from `module.ec2[0]` to `module.compute_ec2[0]`; -- scale-up/scale-down resources move beneath `module.orchestration_webhook[0].module.scale_runners`; -- pool resources move beneath `module.orchestration_webhook[0].module.pool`; and -- job-retry resources move beneath `module.orchestration_webhook[0].module.job_retry`. +The experimental v2 implementation does not retain declarative moves from earlier unpublished module names. Those addresses are not part of the stable contract. An existing experimental deployment must migrate any affected state explicitly before upgrading or accept Terraform's proposed replacement actions. -The runner configuration uses one explicitly named, count-addressed module per concrete provider. Compute modules follow `module.compute_[0]`, while orchestration modules follow `module.orchestration_[0]`. This avoids duplicating the provider name in addresses such as `module.webhook["webhook"]` and gives future providers symmetric addresses such as `module.compute_microvm[0]` and `module.orchestration_scale_set[0]`. Chained moves from the previously published module addresses preserve existing experimental state. - -The canonical v2 output groups resources under `orchestration.webhook`. Direct `scale_up`, `scale_down`, and `pool` outputs remain compatibility aliases during the experimental transition. +The canonical v2 output groups resources under `orchestration_provider.webhook`. Direct `scale_up`, `scale_down`, and `pool` outputs remain compatibility aliases during the experimental transition. This ADR does not define an automatic stable-v1-to-v2 state migration. Existing deployments remain on the stable path until that migration is separately designed and documented. +### Semantic validation lives beside module composition + +Internal runner-config, orchestration-provider, and compute-provider modules keep nested variable declarations focused on types, defaults, and documentation. Their semantic and cross-field checks live in `validations.tf` as lifecycle preconditions on one empty `terraform_data.validate_config` resource per module. The resource has no `input` or `triggers_replace`, so configuration values are not copied into state and ordinary value changes do not replace it. + +This convention requires Terraform 1.4 or newer and adds one state-only validation resource for each instantiated internal module. Known invalid values still fail during planning; unknown conditions may defer until apply, and targeted plans can omit a detached validation resource. Direct module tests therefore target and assert the validation resource explicitly. + ### IAM and encryption follow resource ownership Provider-owned IAM policies use conditional statements for optional KMS keys. A null key omits the statement; policies do not use placeholder account IDs, key IDs, or ARNs to satisfy Terraform typing. @@ -228,13 +228,14 @@ The intended end state permits webhook and scale-set orchestration in the same m - Provider-owned queue, Lambda, artifact, IAM, and output settings have one discoverable namespace. - Exact-one validation prevents ambiguous ownership of a runner configuration. - Stable behavior and shared singleton addresses remain unchanged. -- Moved blocks preserve the addresses already created by the experimental v2 work. +- Provider module addresses follow one consistent compute and orchestration naming convention. ### Negative - The experimental input is more deeply nested than the existing flat interface. - Global webhook defaults and per-runner webhook selection use similarly named blocks with different purposes. - Internal modules have explicit adapter objects and capability contracts that require maintenance. +- Module-level validation resources add state-only objects and are not evaluated by a targeted plan that excludes them. - Adding a stateful provider will still require new runtime, deployment, observability, and failure-recovery design; the Terraform boundary alone does not solve those concerns. - Compatibility aliases temporarily expose both canonical and historical v2 output paths. @@ -256,7 +257,7 @@ This would keep fewer directories initially, but every provider would add resour Scale, pool, and retry are all webhook orchestration behavior. Leaving them under the common module would blur ownership and make a future provider appear to support components it does not use. -**Decision**: Move the leaves under the webhook provider root and preserve state with moved blocks. +**Decision**: Move the leaves under the webhook provider root. Earlier experimental addresses are not retained as an in-module state-migration contract. ### Add the scale-set schema and ECS service now @@ -282,7 +283,7 @@ Implementation and review must verify the boundary at several levels. - Per-runner values override global webhook defaults, and omitted nullable values inherit them. - Shared singleton resources consume global values rather than arbitrary per-runner overrides. - Stable inputs preserve stable resource addresses and output shape. -- The experimental module and child-module renames produce move operations rather than destroy/create operations. +- Canonical runner-config, compute-provider, and orchestration-provider addresses are used consistently in fresh plans. - Canonical nested outputs and compatibility aliases reference the same resources. ### Provider and IAM tests diff --git a/docs/index.md b/docs/index.md index 7020b5f606..8fbe402bf1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -103,21 +103,21 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l 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 top-level `multi_runner_config` entries continue to use the unchanged `runners` module when `experimental.multi_runner_config` is empty. A non-empty experimental map takes priority over the stable map; the maps are not combined. Experimental entries use the new provider-oriented `runner-config`. -Multi-runner centralizes mode selection and canonical configuration in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects the flat globals and stable runner configurations into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base` by applying schema defaults, global/runner-configuration precedence, tag merges, IAM ownership, paths, observability, webhook queues, and provider defaults. Provider selection and the shared runner-binary syncer and discovery use this plan-known base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, webhook queue event mapping, Lambda artifact and principals, the webhook pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, webhook queues, and runner implementations consume that final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, preserving their Terraform addresses while removing a second configuration path. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references into `orchestration.webhook`, and forwards the remaining canonical objects, including the typed orchestration and compute-provider wrappers. +Multi-runner centralizes mode selection and canonical configuration in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects the flat globals and stable runner configurations into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base` by applying schema defaults, global/runner-configuration precedence, tag merges, IAM ownership, paths, observability, webhook queues, and provider defaults. Provider selection and the shared runner-binary syncer and discovery use this plan-known base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, webhook queue event mapping, Lambda artifact and principals, the webhook pool Lambda wrapper, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, webhook queues, and runner implementations consume that final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, preserving their Terraform addresses while removing a second configuration path. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references into `orchestration_provider.webhook`, and forwards the remaining canonical objects, including the typed orchestration and compute-provider wrappers. -The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider`. Root `experimental.lambda` is provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults used across orchestration and non-orchestration consumers. Webhook-specific global defaults live together under `experimental.orchestration.webhook`, including the maximum runner count, the shared webhook's routing and matcher storage, build-queue defaults and encryption, control-plane artifact selectors, and webhook, scale-up, scale-down, and pool Lambda settings. This global block supplies defaults; it does not select an orchestration provider. Each runner configuration separately makes that selection through its own `orchestration` wrapper. The only supported orchestration provider today is `orchestration.webhook`, which owns that runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up and scale-down settings, scheduled pool, and job retry. Keeping those fields behind a typed provider wrapper allows future orchestration providers to be introduced as mutually exclusive siblings without moving the common runner, Lambda substrate, SSM, observability, or compute-provider contracts again. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides remain configuration-specific. A nullable runner-configuration field with a corresponding experimental global inherits that global value when omitted or null. A runner configuration that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. +The `experimental` object provides sibling global defaults through `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider`. Root `experimental.lambda` is provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults used across orchestration and non-orchestration consumers. Webhook-specific global defaults live together under `experimental.orchestration_provider.webhook`, including the maximum runner count, the shared webhook's routing and matcher storage, build-queue defaults and encryption, control-plane artifact selectors, and webhook, scale-up, scale-down, and pool Lambda settings. This global block supplies defaults; it does not select an orchestration provider. Each runner configuration separately makes that selection through its own `orchestration_provider` wrapper. The only supported orchestration provider today is `orchestration_provider.webhook`, which owns that runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up and scale-down settings, scheduled pool, and job retry. Keeping those fields behind a typed provider wrapper allows future orchestration providers to be introduced as mutually exclusive siblings without moving the common runner, Lambda substrate, SSM, observability, or compute-provider contracts again. The shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume the translated global values. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides remain configuration-specific. A nullable runner-configuration field with a corresponding experimental global inherits that global value when omitted or null. A runner configuration that selects an external runner IAM role intentionally suppresses inherited managed policies and additional trust policy JSON because the module does not manage that role. Tag maps merge from broad to narrow. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. -Global `experimental.orchestration.webhook.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Runner-configuration `experimental.multi_runner_config[].orchestration.webhook.queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue, and its CMK is independent from `experimental.ssm.kms_key_id`. Runner-config forwards the distinct build-queue key to the webhook orchestration provider: scale-up receives `kms:Decrypt`, while job-retry receives `kms:Decrypt` and `kms:GenerateDataKey` for publishing. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when that publisher targets customer-managed encrypted queues. For v2, `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. +Global `experimental.orchestration_provider.webhook.queue` owns v2 defaults for build-queue delay (`30` seconds), retention (`86400` seconds), visibility (`180` seconds), redrive, tags, and encryption. Runner-configuration `experimental.multi_runner_config[].orchestration_provider.webhook.queue` fields override the global delay, retention, visibility, redrive, and tag values; encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. If the block is supplied explicitly, all three leaf keys are required: use a non-null `sqs_managed_sse_enabled` with null KMS fields for the non-KMS mode, or set that field to null and provide `kms_master_key_id` for KMS mode. This encryption configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue, and its CMK is independent from `experimental.ssm.kms_key_id`. Runner-config forwards the distinct build-queue key to the webhook orchestration provider: scale-up receives `kms:Decrypt`, while job-retry receives `kms:Decrypt` and `kms:GenerateDataKey` for publishing. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when that publisher targets customer-managed encrypted queues. For v2, `experimental.multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds` must be at least six times the resolved `experimental.multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`; the Lambda timeout does not itself configure queue visibility. The v1 translation continues to use `runners_scale_up_lambda_timeout` and flat `queue_encryption`. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner configurations consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-config GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. Both client settings belong inside `experimental.github`; they are not root `experimental` fields or per-configuration orchestration settings. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner configurations consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration_provider.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-config GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. Both client settings belong inside `experimental.github`; they are not root `experimental` fields or per-configuration orchestration settings. -The shared webhook, runner configurations, SSM housekeepers, runner-binary syncer, termination watcher, and AMI housekeeper consume the provider-neutral runtime, architecture, networking, role, and tag defaults under `experimental.lambda`; `lambda.principals` configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global observability settings configure logging and tracing, and global metrics also configure the termination watcher. The runner-control artifact shared by webhook scale, pool, and job-retry is selected globally under `experimental.orchestration.webhook.lambda.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. `experimental.orchestration.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `experimental.orchestration.webhook.lambda.webhook` owns the separate ingress webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. +The shared webhook, runner configurations, SSM housekeepers, runner-binary syncer, termination watcher, and AMI housekeeper consume the provider-neutral runtime, architecture, networking, role, and tag defaults under `experimental.lambda`; `lambda.principals` configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global observability settings configure logging and tracing, and global metrics also configure the termination watcher. The runner-control artifact shared by webhook scale, pool, and job-retry is selected globally under `experimental.orchestration_provider.webhook.lambda.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. `experimental.orchestration_provider.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `experimental.orchestration_provider.webhook.lambda.webhook` owns the separate ingress webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. `experimental.compute_provider.ec2.runner_binaries.enabled` defaults each EC2 runner configuration to the shared synchronized distribution, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. Global `ssm.paths.root` is the base for shared and runner-configuration-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the runner-configuration key only for configuration-owned paths. The default derived base is `/github-action-runners/${prefix}`, and runner token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration; it does not select encryption for runtime-created runner parameters. Webhook-provider leaves conditionally omit their KMS statements when this value is null, while apply-time-unknown key ARNs remain valid during planning. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than per-configuration overrides. -Each runner configuration selects two independent typed providers: one `orchestration` provider for demand control and one `compute_provider` for runner capacity. `orchestration.webhook` is the sole supported orchestration provider today. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider objects reach runner-config, which validates each exact-one selection and dispatches the matching root-level provider module. `orchestration-providers/webhook` owns scale-up, scale-down, scheduled pool, job retry, and their webhook-specific defaults and tag layering; runner-config retains common SSM housekeeping plus 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 modules are internal experimental implementation boundaries, not standalone public entry points. Later releases can add mutually exclusive orchestration siblings without changing the common or compute-provider contracts. 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. +Each runner configuration selects two independent typed providers: one `orchestration_provider` provider for demand control and one `compute_provider` for runner capacity. `orchestration_provider.webhook` is the sole supported orchestration provider today. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider objects reach runner-config, which validates each exact-one selection and dispatches the matching root-level provider module. `orchestration-providers/webhook` owns scale-up, scale-down, scheduled pool, job retry, and their webhook-specific defaults and tag layering; runner-config retains common SSM housekeeping plus 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 modules are internal experimental implementation boundaries, not standalone public entry points. Later releases can add mutually exclusive orchestration siblings without changing the common or compute-provider contracts. 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 2723f0f57d..06429422e0 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -30,11 +30,11 @@ The EC2 provider owns the instance profile, launch template, security group, AMI Runner-config, the root orchestration and compute providers, and their leaf modules are internal implementation boundaries rather than standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config`; `multi-runner` calls `runner-config`, which selects the provider modules. Their direct input and output contracts may change while v2 remains experimental. -Each external v2 runner configuration selects demand orchestration separately from its compute provider. The required `orchestration` wrapper has one supported provider today: `experimental.multi_runner_config..orchestration.webhook`. It owns the runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up, scale-down, pool, and job-retry settings. The wrapper is intentionally typed as a provider boundary so later orchestration implementations can be added as mutually exclusive siblings without moving common configuration fields again. +Each external v2 runner configuration selects demand orchestration separately from its compute provider. The required `orchestration_provider` wrapper has one supported provider today: `experimental.multi_runner_config..orchestration_provider.webhook`. It owns the runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up, scale-down, pool, and job-retry settings. The wrapper is intentionally typed as a provider boundary so later orchestration implementations can be added as mutually exclusive siblings without moving common configuration fields again. The runner configuration also populates exactly one typed compute-provider block, such as `experimental.multi_runner_config..compute_provider.ec2`; that block's presence must be known during planning because it determines capacity routing. Multi-runner module validation enforces both selections through resource preconditions, while each provider implementation owns its provider-specific semantic validation. -After resolving global `experimental.compute_provider.ec2` values with the selected runner configuration's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-config`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-config validates that exactly one compute-provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It independently validates the exact-one `orchestration = { webhook = { ... } }` wrapper and invokes the selected root orchestration provider with provider-neutral common objects such as `runner`, the Lambda substrate, SSM, observability, and the selected compute-provider capabilities. +After resolving global `experimental.compute_provider.ec2` values with the selected runner configuration's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-config`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-config validates that exactly one compute-provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It independently validates the exact-one `orchestration_provider = { webhook = { ... } }` wrapper and invokes the selected root orchestration provider with provider-neutral common objects such as `runner`, the Lambda substrate, SSM, observability, and the selected compute-provider capabilities. Binary discovery is completed before the runner-config call. `config.experimental.translation.tf` enriches the final canonical runner configuration at `compute_provider.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_configs` call then passes that runner configuration's wrapped `compute_provider` object unchanged. Runner-config and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.binaries_syncer`. @@ -57,10 +57,10 @@ The trust-policy output depends only on its input documents, not on the full pro Multi-runner produces one canonical consumer representation for both input modes: 1. `config.experimental.translation.tf` selects the module mode and builds `local.raw_translated_experimental`. A non-empty experimental runner-configuration map selects the nested `var.experimental` input for v2; otherwise the file projects flat module globals and stable `multi_runner_config` entries into the same schema for v1. -2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/runner-configuration precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, `orchestration.webhook`, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. +2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/runner-configuration precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, `orchestration_provider.webhook`, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. 3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, webhook queue event mapping, Lambda artifact and principals, the webhook pool Lambda wrapper, SSM KMS, and each enabled EC2 runner configuration's `compute_provider.ec2.binaries_syncer.s3`. The remaining shared components, webhook queues, and runner implementations consume this final canonical object. -Stable translation always emits `orchestration.webhook`, but stable runner configurations remain on `module.runners["configuration"]`: `runners.tf` adapts each final canonical runner configuration back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate configuration source. This is not the phase-2 implementation migration to `runner-config`. For v2, `module.runner_configs` directly iterates the gated final runner-configuration map. Its input arguments inline the environment tag and live GitHub App and build-queue references into `orchestration.webhook`, then forward the complete orchestration and compute-provider wrappers. Binary output enrichment and all other derived configuration shaping are already complete in canonical translation. +Stable translation always emits `orchestration_provider.webhook`, but stable runner configurations remain on `module.runners["configuration"]`: `runners.tf` adapts each final canonical runner configuration back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate configuration source. This is not the phase-2 implementation migration to `runner-config`. For v2, `module.runner_configs` directly iterates the gated final runner-configuration map. Its input arguments inline the environment tag and live GitHub App and build-queue references into `orchestration_provider.webhook`, then forward the complete orchestration and compute-provider wrappers. Binary output enrichment and all other derived configuration shaping are already complete in canonical translation. ## Phase 1 dispatch and compatibility @@ -98,10 +98,10 @@ The canonical object gives shared singleton resources one global representation - When `experimental.multi_runner_config` is empty, every key in the stable top-level `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address. - Flat v1 inputs are projected into `raw_translated_experimental`, resolved into `translated_experimental_base`, finalized as `translated_experimental`, and then adapted by `runners.tf` to the existing child-module arguments. - The v1 translation uses `runners_scale_up_lambda_timeout` for build-queue visibility, preserving the stable flat behavior. -- The v1 translation wraps its existing registration scope, matcher, queue, scale, pool, and retry values under `orchestration.webhook`; the stable public input and resource behavior remain unchanged. +- The v1 translation wraps its existing registration scope, matcher, queue, scale, pool, and retry values under `orchestration_provider.webhook`; the stable public input and resource behavior remain unchanged. - Stable queue tagging and the flat `runners_map` output remain unchanged. - When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-config` at `module.runner_configs["configuration"]`; stable-map entries are not dispatched. -- Declarative `moved` blocks preserve historical runner-config and provider-child addresses, then converge compute and orchestration resources beneath `module.compute_ec2[0]` and `module.orchestration_webhook[0]`. +- Experimental v2 uses the canonical `module.runner_configs`, `module.compute_ec2[0]`, and `module.orchestration_webhook[0]` addresses. Earlier experimental addresses are not migrated automatically. - Experimental resources are exposed separately through the nested `runners_map_v2` output. - The maps are not combined. A non-empty v2 map has explicit priority over the stable map. @@ -109,7 +109,7 @@ No v1-to-v2 state move is included in phase 1. Enabling v2 for a module instance ## Opting in -Nested global settings are the source of defaults for v2 runner configurations and the applicable singleton shared components. The GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume translated globals. Every v2 runner configuration must currently select `orchestration.webhook`; no other orchestration provider is implemented. The wrapper is the durable provider boundary for future mutually exclusive siblings. Migrated v2 consumers do not fall back to matching flat inputs; those values seed the stable-mode translation only. The singleton-specific webhook, binary-syncer, termination-watcher, and AMI-housekeeper settings all have nested owners. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Per-configuration values override globals only inside that runner configuration and never configure singleton resources. Nested defaults mirror established v1 behavior, while nullable runner-configuration fields inherit the corresponding global when omitted or set to null. Set a global override only when the value is genuinely shared by every runner configuration, and put configuration-specific differences in that configuration itself. An external `runner.iam.role` is the exception to normal inheritance: inherited managed policies and additional trust policy JSON are suppressed because the module does not manage that role. +Nested global settings are the source of defaults for v2 runner configurations and the applicable singleton shared components. The GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume translated globals. Every v2 runner configuration must currently select `orchestration_provider.webhook`; no other orchestration provider is implemented. The wrapper is the durable provider boundary for future mutually exclusive siblings. Migrated v2 consumers do not fall back to matching flat inputs; those values seed the stable-mode translation only. The singleton-specific webhook, binary-syncer, termination-watcher, and AMI-housekeeper settings all have nested owners. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Per-configuration values override globals only inside that runner configuration and never configure singleton resources. Nested defaults mirror established v1 behavior, while nullable runner-configuration fields inherit the corresponding global when omitted or set to null. Set a global override only when the value is genuinely shared by every runner configuration, and put configuration-specific differences in that configuration itself. An external `runner.iam.role` is the exception to normal inheritance: inherited managed policies and additional trust policy JSON are suppressed because the module does not manage that role. ```hcl module "multi_runner" { @@ -165,7 +165,7 @@ module "multi_runner" { # Global defaults are grouped by orchestration provider. This block does # not select a provider; each runner configuration has its own exact-one # orchestration selector below. - orchestration = { + orchestration_provider = { webhook = { runner = { boot_time_in_minutes = 5 @@ -404,7 +404,7 @@ module "multi_runner" { # provider. Webhook is the only supported provider today; future # providers can be added as mutually exclusive siblings without moving # these fields again. - orchestration = { + orchestration_provider = { webhook = { # This runner configuration overrides the webhook provider's global cap. runner = { @@ -466,19 +466,19 @@ module "multi_runner" { ## Inputs, tags, and outputs -The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-configuration map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configurations and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-configuration SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration.webhook`: runner lifecycle, boot time, and maximum count; repository filtering; shared routing and matcher storage; queue defaults and encryption; the runner-control artifact shared by scale, pool, and job-retry; and the ingress webhook, scale, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner configuration's separate `orchestration` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. +The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-configuration map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configurations and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-configuration SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration_provider.webhook`: runner lifecycle, boot time, and maximum count; repository filtering; shared routing and matcher storage; queue defaults and encryption; the runner-control artifact shared by scale, pool, and job-retry; and the ingress webhook, scale, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner configuration's separate `orchestration_provider` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. -Each v2 runner configuration groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration` provider wrapper. Its sole supported block today is `orchestration.webhook`, containing provider-owned runner lifecycle, boot-time, and capacity settings, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale.up`, `lambda.scale.down`, `lambda.pool`, and `job_retry`. A nullable per-configuration field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner configuration is therefore a non-null configuration override followed by the global nested value, including that field's nested schema default. Per-configuration precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. +Each v2 runner configuration groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration_provider` provider wrapper. Its sole supported block today is `orchestration_provider.webhook`, containing provider-owned runner lifecycle, boot-time, and capacity settings, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale.up`, `lambda.scale.down`, `lambda.pool`, and `job_retry`. A nullable per-configuration field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner configuration is therefore a non-null configuration override followed by the global nested value, including that field's nested schema default. Per-configuration precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. -Global `experimental.orchestration.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-configuration redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration.webhook.queue` override those global defaults, and runner-configuration queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. +Global `experimental.orchestration_provider.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-configuration redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration_provider.webhook.queue` override those global defaults, and runner-configuration queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. -Queue encryption is global-only. Omitting the entire `experimental.orchestration.webhook.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue. Runner configurations cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent and are forwarded separately to `orchestration-providers/webhook`: scale-up receives queue-key `kms:Decrypt`, job-retry receives queue-key `kms:Decrypt` and `kms:GenerateDataKey`, and both retain separate Parameter Store decrypt statements. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when it publishes to customer-managed encrypted queues. The v1 translation retains the flat contract: per-configuration delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. +Queue encryption is global-only. Omitting the entire `experimental.orchestration_provider.webhook.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue. Runner configurations cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent and are forwarded separately to `orchestration-providers/webhook`: scale-up receives queue-key `kms:Decrypt`, job-retry receives queue-key `kms:Decrypt` and `kms:GenerateDataKey`, and both retain separate Parameter Store decrypt statements. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when it publishes to customer-managed encrypted queues. The v1 translation retains the flat contract: per-configuration delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. -Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner configurations: `app` is required and `additional_apps` defaults to `[]`. `experimental.orchestration.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-config client settings. Neither field is a root `experimental` sibling. Per-configuration `orchestration.webhook.github.organization_runners` is the separate registration-scope setting; runner configurations do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. +Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner configurations: `app` is required and `additional_apps` defaults to `[]`. `experimental.orchestration_provider.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-config client settings. Neither field is a root `experimental` sibling. Per-configuration `orchestration_provider.webhook.github.organization_runners` is the separate registration-scope setting; runner configurations do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner configuration consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. -Global `experimental.orchestration.webhook` configures the shared webhook's queue-selection strategy, EventBridge implementation and accepted events, and matcher-configuration Parameter Store tier in addition to its queue and Lambda component defaults. `orchestration.webhook.eventbridge.enable` and the matcher tier must be known during planning because they select module or parameter-chunk shape. `first` deterministically chooses the first equally matched queue by priority, `random` spreads jobs among equals, and `all` sends a job to every match at the cost of multiple runner launches and registrations. +Global `experimental.orchestration_provider.webhook` configures the shared webhook's queue-selection strategy, EventBridge implementation and accepted events, and matcher-configuration Parameter Store tier in addition to its queue and Lambda component defaults. `orchestration_provider.webhook.eventbridge.enable` and the matcher tier must be known during planning because they select module or parameter-chunk shape. `first` deterministically chooses the first equally matched queue by priority, `random` spreads jobs among equals, and `all` sends a job to every match at the cost of multiple runner launches and registrations. Global `ssm.paths.root` is the base for shared GitHub App and webhook parameters and for every runner configuration. Shared resources append the global-only `ssm.paths.app` and `ssm.paths.webhook` segments, which default to `app` and `webhook`; normalization appends the runner-configuration key only for configuration-owned roots, keeping runner parameters isolated. The derived global base defaults to `/github-action-runners/${prefix}`, while runner token and config segments default to `runners/tokens` and `runners/config`. Global `ssm.tags` augments the base tags on the shared Parameter Store module and also defaults configuration-owned SSM tags; `ssm.parameters.tags` remains specific to configuration-managed and runtime-created runner parameters. The global housekeeper defaults preserve the established schedule, enabled state, Lambda artifact, sizing, and cleanup behavior; a nullable per-configuration field inherits those values. Avoid setting a global `ssm.housekeeper.config.tokenPath` unless every runner configuration is intentionally meant to clean the same path; omitting it lets each runner configuration derive its isolated token path. @@ -488,13 +488,13 @@ The global `experimental.compute_provider` block owns v2 defaults for EC2 settin `experimental.compute_provider.ec2.runner_binaries` owns whether EC2 runner configurations use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. -Webhook-orchestration runner-control artifacts are selected globally through `experimental.orchestration.webhook.lambda.artifact.zip` or `experimental.orchestration.webhook.lambda.artifact.s3.{key,object_version}` and shared by scale, pool, and job-retry. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The ingress webhook artifact remains separate under `experimental.orchestration.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. +Webhook-orchestration runner-control artifacts are selected globally through `experimental.orchestration_provider.webhook.lambda.artifact.zip` or `experimental.orchestration_provider.webhook.lambda.artifact.s3.{key,object_version}` and shared by scale, pool, and job-retry. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The ingress webhook artifact remains separate under `experimental.orchestration_provider.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. -Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-configuration tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes: shared SSM merges `experimental.tags` with `ssm.tags`, the webhook merges `experimental.lambda.tags` with `experimental.orchestration.webhook.lambda.webhook.tags`, and the runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags` and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-configuration `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. +Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-configuration tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes: shared SSM merges `experimental.tags` with `ssm.tags`, the webhook merges `experimental.lambda.tags` with `experimental.orchestration_provider.webhook.lambda.webhook.tags`, and the runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags` and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-configuration `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and runner-configuration log-group tags. Tracing stays under `observability.tracing`, and metrics enablement, namespace, and individual metric switches stay under `observability.metrics`. -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`, demand-control resources under `orchestration.webhook`, and provider-specific resources under `provider.`. The provider key is derived from the selected typed compute-provider block. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, scale-up resources at `runners_map_v2["configuration"].orchestration.webhook.scale_up`, and launch-template and runner-log artifacts under `runners_map_v2["configuration"].provider.ec2`. The webhook `pool` value is null when no pool configuration is supplied. +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`, demand-control resources under `orchestration_provider.webhook`, and provider-specific resources under `provider.`. The provider key is derived from the selected typed compute-provider block. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, scale-up resources at `runners_map_v2["configuration"].orchestration_provider.webhook.scale_up`, and launch-template and runner-log artifacts under `runners_map_v2["configuration"].provider.ec2`. The webhook `pool` value is null when no pool configuration is supplied. ## Plan-time provider selection and IAM shape @@ -519,7 +519,7 @@ compute_provider = { } ``` -The populated `ec2` block tells both multi-runner routing and runner-config dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_configs` input forwards it unchanged at the runner-config boundary. The orchestration wrapper follows the same exact-one rule. Within the compute block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. `ssm.kms_key_id`, `orchestration.webhook.queue.kms_key_id`, and values such as `observability.logs.kms_key_id` remain nullable scalar inputs even when their ARNs are unknown until apply. +The populated `ec2` block tells both multi-runner routing and runner-config dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_configs` input forwards it unchanged at the runner-config boundary. The orchestration_provider wrapper follows the same exact-one rule. Within the compute block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. `ssm.kms_key_id`, `orchestration_provider.webhook.queue.kms_key_id`, and values such as `observability.logs.kms_key_id` remain nullable scalar inputs even when their ARNs are unknown until apply. For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts the shared GitHub App parameters, configures the webhook with the same key, and adds matching decrypt permissions to every runner configuration so its control-plane functions can read those credentials. Its value may be unknown until apply. It does not select encryption for runtime-created runner parameters. Queue encryption is a separate global contract, may use a different CMK, and reaches only the scale-up consumer and job-retry publisher policies inside the webhook orchestration provider. diff --git a/modules/compute-providers/ec2/trust-policy/README.md b/modules/compute-providers/ec2/trust-policy/README.md index 58d0f0e67d..f73dc49b9b 100644 --- a/modules/compute-providers/ec2/trust-policy/README.md +++ b/modules/compute-providers/ec2/trust-policy/README.md @@ -15,6 +15,7 @@ This internal submodule builds the EC2 runner-role trust policy independently fr | Name | Version | |------|---------| | [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -24,6 +25,7 @@ No modules. | Name | Type | |------|------| +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [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.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | diff --git a/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl b/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl index e764e63fd4..be297e0f10 100644 --- a/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl +++ b/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl @@ -63,5 +63,9 @@ run "rejects_invalid_additional_trust_policy" { additional_trust_policy_json = "not-json" } - expect_failures = [var.additional_trust_policy_json] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } diff --git a/modules/compute-providers/ec2/trust-policy/validations.tf b/modules/compute-providers/ec2/trust-policy/validations.tf new file mode 100644 index 0000000000..351a1b654f --- /dev/null +++ b/modules/compute-providers/ec2/trust-policy/validations.tf @@ -0,0 +1,8 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + condition = var.additional_trust_policy_json == null ? true : can(jsondecode(var.additional_trust_policy_json)) + error_message = "additional_trust_policy_json must be valid JSON when set." + } + } +} diff --git a/modules/compute-providers/ec2/trust-policy/variables.tf b/modules/compute-providers/ec2/trust-policy/variables.tf index 875fa44f4a..8afb35268e 100644 --- a/modules/compute-providers/ec2/trust-policy/variables.tf +++ b/modules/compute-providers/ec2/trust-policy/variables.tf @@ -2,9 +2,4 @@ variable "additional_trust_policy_json" { description = "Optional IAM policy document merged with the default EC2 runner-role trust policy." type = string default = null - - validation { - condition = var.additional_trust_policy_json == null ? true : can(jsondecode(var.additional_trust_policy_json)) - error_message = "additional_trust_policy_json must be valid JSON when set." - } } diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index ed02883f89..4c397b8a47 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -25,33 +25,33 @@ 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. -A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-config` at `module.runner_configs["configuration"]`; a module-level moved block maps the former `module.runner_stacks` address to this composition name without recreating existing v2 resources. Sibling `experimental.tags`, `roles`, `runner`, `github`, `lambda`, `orchestration`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each runner configuration can override supported configuration-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides affect only that runner configuration, never a singleton shared component. A nullable configuration field inherits its global value. Within v2 queue and runner-config scopes, tags merge from global to configuration and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. +A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-config` at `module.runner_configs["configuration"]`; earlier experimental module addresses are not migrated automatically. Sibling `experimental.tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each runner configuration can override supported configuration-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides affect only that runner configuration, never a singleton shared component. A nullable configuration field inherits its global value. Within v2 queue and runner-config scopes, tags merge from global to configuration and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. -The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, configuration-over-global precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, the webhook-owned runner-control artifact, Lambda principals and shared artifact bucket, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references under `orchestration.webhook`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. +The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, configuration-over-global precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, the webhook-owned runner-control artifact, Lambda principals and shared artifact bucket, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references under `orchestration_provider.webhook`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. -V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner configurations; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. +V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner configurations; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration_provider.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. -Shared singleton resources use the translated global contract without accepting per-configuration overrides. The shared webhook and its webhook-secret parameter retain their historical singleton addresses and are always created; only build-queue and matcher membership is filtered to runner configurations that select `orchestration.webhook`, so future non-webhook providers do not change the singleton lifecycle. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Global `experimental.lambda` contains only that shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults. Workflow-job webhook control-plane defaults live under `experimental.orchestration.webhook`. The runner-control artifact shared by scale, pool, and job-retry comes from `experimental.orchestration.webhook.lambda.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Each runner configuration's SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the same shared artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation maps the legacy runner artifact into both canonical runner-control and SSM-housekeeper component contracts while preserving S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the ingress webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. `experimental.orchestration.webhook` owns runner lifecycle, repository filtering, queue selection, EventBridge routing, accepted event types, matcher-parameter tier, queue defaults, and webhook/scale/pool Lambda component defaults. Its `lambda.webhook` block owns the ingress webhook's separate artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. +Shared singleton resources use the translated global contract without accepting per-configuration overrides. The shared webhook and its webhook-secret parameter retain their historical singleton addresses and are always created; only build-queue and matcher membership is filtered to runner configurations that select `orchestration_provider.webhook`, so future non-webhook providers do not change the singleton lifecycle. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Global `experimental.lambda` contains only that shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults. Workflow-job webhook control-plane defaults live under `experimental.orchestration_provider.webhook`. The runner-control artifact shared by scale, pool, and job-retry comes from `experimental.orchestration_provider.webhook.lambda.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Each runner configuration's SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the same shared artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation maps the legacy runner artifact into both canonical runner-control and SSM-housekeeper component contracts while preserving S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the ingress webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. `experimental.orchestration_provider.webhook` owns runner lifecycle, repository filtering, queue selection, EventBridge routing, accepted event types, matcher-parameter tier, queue defaults, and webhook/scale/pool Lambda component defaults. Its `lambda.webhook` block owns the ingress webhook's separate artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for configuration-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the configuration key only to runner-configuration roots. The default derived base is `/github-action-runners/${prefix}`, and runner-configuration token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration. It does not select encryption for runtime-created runner-configuration parameters. Provider-owned runner-config IAM omits the KMS statement when the key is null and still accepts an ARN whose value is unknown until apply; the unchanged shared webhook retains its legacy policy handling. -Each runner configuration selects exactly one typed `orchestration` provider and exactly one typed `compute_provider`; the corresponding global blocks supply defaults without selecting providers. `runner-config` validates both selections, owns the common runner role and SSM housekeeper, and connects compute-provider capabilities to the selected orchestration provider. `orchestration-providers/webhook` owns scale-up, scale-down, pool, and job-retry resources. The EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. Provider-block selection must be known during planning because it determines the module graph. These child modules are internal implementation boundaries rather than standalone public entry points. EC2 remains the only Terraform-managed runner provider; MicroVM, CodeBuild, and other provider modules are future work. +Each runner configuration selects exactly one typed `orchestration_provider` provider and exactly one typed `compute_provider`; the corresponding global blocks supply defaults without selecting providers. `runner-config` validates both selections, owns the common runner role and SSM housekeeper, and connects compute-provider capabilities to the selected orchestration provider. `orchestration-providers/webhook` owns scale-up, scale-down, pool, and job-retry resources. The EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. Provider-block selection must be known during planning because it determines the module graph. These child modules are internal implementation boundaries 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 runner-config when it creates the role. An external role remains unmanaged and must already contain the required policies. Phase 1 supports both input contracts with deterministic precedence. When `experimental.multi_runner_config` is empty, the stable top-level `multi_runner_config` follows the unchanged legacy path. When the experimental map is non-empty, it becomes the complete runner map and stable entries are ignored. The maps are not merged. -Global `experimental.orchestration.webhook.queue` owns the v2 defaults for build-queue delay, retention, visibility, redrive, tags, and encryption. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. Redrive defaults to disabled with a null `maxReceiveCount`; a null configuration wrapper or leaf inherits the matching global value, and an enabled result requires `maxReceiveCount` greater than zero. Configuration fields under `multi_runner_config[].orchestration.webhook.queue` override the corresponding global queue defaults, and configuration tags merge over global queue tags. Encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. When supplying the block explicitly, provide all three leaves and use null for inactive KMS or SQS-managed settings. A non-null `kms_master_key_id` must be a KMS key ARN—not a key ID or alias—because it also becomes an IAM resource; a computed ARN may remain unknown until apply. This block configures the multi-runner build queues and their dead-letter queues, not runner-config job-retry queues. Its CMK is independent from `experimental.ssm.kms_key_id` and is forwarded separately to each webhook runner configuration: provider-owned IAM grants scale-up `kms:Decrypt` and job-retry `kms:Decrypt` plus `kms:GenerateDataKey`. The shared webhook module is intentionally unchanged, so its producer role does not derive queue-CMK permissions from this field; ensure the key policy or caller-managed IAM covers producer access when required. +Global `experimental.orchestration_provider.webhook.queue` owns the v2 defaults for build-queue delay, retention, visibility, redrive, tags, and encryption. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. Redrive defaults to disabled with a null `maxReceiveCount`; a null configuration wrapper or leaf inherits the matching global value, and an enabled result requires `maxReceiveCount` greater than zero. Configuration fields under `multi_runner_config[].orchestration_provider.webhook.queue` override the corresponding global queue defaults, and configuration tags merge over global queue tags. Encryption remains global-only. Omitting the whole encryption block selects SQS-managed encryption and null KMS attributes. When supplying the block explicitly, provide all three leaves and use null for inactive KMS or SQS-managed settings. A non-null `kms_master_key_id` must be a KMS key ARN—not a key ID or alias—because it also becomes an IAM resource; a computed ARN may remain unknown until apply. This block configures the multi-runner build queues and their dead-letter queues, not runner-config job-retry queues. Its CMK is independent from `experimental.ssm.kms_key_id` and is forwarded separately to each webhook runner configuration: provider-owned IAM grants scale-up `kms:Decrypt` and job-retry `kms:Decrypt` plus `kms:GenerateDataKey`. The shared webhook module is intentionally unchanged, so its producer role does not derive queue-CMK permissions from this field; ensure the key policy or caller-managed IAM covers producer access when required. -For v2, `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. +For v2, `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. Global `experimental.compute_provider.ec2.runner_binaries` owns the shared distribution-bucket and syncer defaults. It covers runner-configuration enablement, bucket encryption, tags, versioning and access logging, plus the syncer artifact, Lambda sizing, and schedule. `runner_binaries.enabled` defaults to `true`, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides that default; enabled distributions are created once per unique operating-system and architecture pair. The resolved enable value, `s3.encryption.enabled`, the nullness of `s3.encryption.kms_master_key_id`, and the nullness of `s3.logging.bucket` must be known during planning because they determine module or resource shape. A distribution CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from it; attach that permission separately when using a CMK. ### V2 tagging -For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `orchestration.webhook.queue.tags`, and configuration-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `orchestration.webhook.lambda.scale.up.tags`, `orchestration.webhook.lambda.scale.down.tags`, `orchestration.webhook.lambda.webhook.tags`, `orchestration.webhook.lambda.pool.tags`, `orchestration.webhook.job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `orchestration.webhook.lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain configuration-owned runner-config log-group tags. In stable mode, translation preserves the existing flat behavior. +For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `orchestration_provider.webhook.queue.tags`, and configuration-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `orchestration_provider.webhook.lambda.scale.up.tags`, `orchestration_provider.webhook.lambda.scale.down.tags`, `orchestration_provider.webhook.lambda.webhook.tags`, `orchestration_provider.webhook.lambda.pool.tags`, `orchestration_provider.webhook.job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `orchestration_provider.webhook.lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain configuration-owned runner-config log-group tags. In stable mode, translation preserves the existing flat behavior. -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`, with demand-control resources canonically grouped under `orchestration.webhook`, while `runners_map` is empty. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].orchestration.webhook.scale_up.lambda`, `.log_group`, and `.role` for scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. Existing flat `scale_up`, `scale_down`, and `pool` output aliases remain available for compatibility. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. +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`, with demand-control resources canonically grouped under `orchestration_provider.webhook`, while `runners_map` is empty. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].orchestration_provider.webhook.scale_up.lambda`, `.log_group`, and `.role` for scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. Existing flat `scale_up`, `scale_down`, and `pool` output aliases remain available for compatibility. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. ### Multi-runner v2 migration roadmap @@ -216,7 +216,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration`, where exactly one typed provider block must be non-null.
- `orchestration.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

})), {})
})
| `{}` | 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null.
- `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

})), {})
})
| `{}` | 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
}))
})
| `null` | no | @@ -290,7 +290,7 @@ module "multi-runner" { | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | 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. The orchestration object is canonical; scale\_up, scale\_down, and pool remain compatibility aliases. | +| [runners\_map\_v2](#output\_runners\_map\_v2) | Experimental v2 runner resources keyed by runner configuration. The `orchestration_provider` object is canonical; `scale_up`, `scale_down`, and `pool` remain compatibility aliases. | | [ssm\_parameters](#output\_ssm\_parameters) | n/a | | [webhook](#output\_webhook) | n/a | diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index bf3f5ff607..a391be5b9a 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -68,7 +68,7 @@ locals { } } - orchestration = { + orchestration_provider = { webhook = { queue_selection_strategy = var.queue_selection_strategy eventbridge = var.eventbridge @@ -338,7 +338,7 @@ locals { } } - orchestration = { + orchestration_provider = { webhook = { runner = { boot_time_in_minutes = v.runner_config.runner_boot_time_in_minutes @@ -586,81 +586,81 @@ locals { } }) - orchestration = { - webhook = v.orchestration.webhook == null ? null : merge(v.orchestration.webhook, { + orchestration_provider = { + webhook = v.orchestration_provider.webhook == null ? null : merge(v.orchestration_provider.webhook, { runner = { boot_time_in_minutes = coalesce( - v.orchestration.webhook.runner.boot_time_in_minutes, - local.raw_translated_experimental.orchestration.webhook.runner.boot_time_in_minutes, + v.orchestration_provider.webhook.runner.boot_time_in_minutes, + local.raw_translated_experimental.orchestration_provider.webhook.runner.boot_time_in_minutes, ) ephemeral = coalesce( - v.orchestration.webhook.runner.ephemeral, - local.raw_translated_experimental.orchestration.webhook.runner.ephemeral, + v.orchestration_provider.webhook.runner.ephemeral, + local.raw_translated_experimental.orchestration_provider.webhook.runner.ephemeral, ) jit_config_enabled = try(coalesce( - v.orchestration.webhook.runner.jit_config_enabled, - local.raw_translated_experimental.orchestration.webhook.runner.jit_config_enabled, + v.orchestration_provider.webhook.runner.jit_config_enabled, + local.raw_translated_experimental.orchestration_provider.webhook.runner.jit_config_enabled, ), null) maximum_count = try(coalesce( - v.orchestration.webhook.runner.maximum_count, - local.raw_translated_experimental.orchestration.webhook.runner.maximum_count, + v.orchestration_provider.webhook.runner.maximum_count, + local.raw_translated_experimental.orchestration_provider.webhook.runner.maximum_count, ), null) } - lambda = merge(v.orchestration.webhook.lambda, { - scale = merge(v.orchestration.webhook.lambda.scale, { - up = merge(v.orchestration.webhook.lambda.scale.up, { - memory_size = coalesce(v.orchestration.webhook.lambda.scale.up.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.memory_size) - timeout = coalesce(v.orchestration.webhook.lambda.scale.up.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.timeout) - reserved_concurrent_executions = coalesce(v.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions) - job_queued_check_enabled = try(coalesce(v.orchestration.webhook.lambda.scale.up.job_queued_check_enabled, local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.job_queued_check_enabled), null) + lambda = merge(v.orchestration_provider.webhook.lambda, { + scale = merge(v.orchestration_provider.webhook.lambda.scale, { + up = merge(v.orchestration_provider.webhook.lambda.scale.up, { + memory_size = coalesce(v.orchestration_provider.webhook.lambda.scale.up.memory_size, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.memory_size) + timeout = coalesce(v.orchestration_provider.webhook.lambda.scale.up.timeout, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.timeout) + reserved_concurrent_executions = coalesce(v.orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions) + job_queued_check_enabled = try(coalesce(v.orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled), null) event_source_mapping = { batch_size = coalesce( - v.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size, - local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size, + v.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size, + local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size, ) maximum_batching_window_in_seconds = coalesce( - v.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds, - local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds, + v.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds, + local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds, ) } - tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.tags, v.orchestration.webhook.lambda.scale.up.tags) + tags = merge(local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.tags, v.orchestration_provider.webhook.lambda.scale.up.tags) }) - down = merge(v.orchestration.webhook.lambda.scale.down, { - memory_size = coalesce(v.orchestration.webhook.lambda.scale.down.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.memory_size) - timeout = coalesce(v.orchestration.webhook.lambda.scale.down.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.timeout) - schedule_expression = coalesce(v.orchestration.webhook.lambda.scale.down.schedule_expression, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.schedule_expression) - minimum_running_time_in_minutes = try(coalesce(v.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes, local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes), null) - idle_config = v.orchestration.webhook.lambda.scale.down.idle_config != null ? v.orchestration.webhook.lambda.scale.down.idle_config : local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.idle_config - tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.scale.down.tags, v.orchestration.webhook.lambda.scale.down.tags) + down = merge(v.orchestration_provider.webhook.lambda.scale.down, { + memory_size = coalesce(v.orchestration_provider.webhook.lambda.scale.down.memory_size, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.down.memory_size) + timeout = coalesce(v.orchestration_provider.webhook.lambda.scale.down.timeout, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.down.timeout) + schedule_expression = coalesce(v.orchestration_provider.webhook.lambda.scale.down.schedule_expression, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.down.schedule_expression) + minimum_running_time_in_minutes = try(coalesce(v.orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes, local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes), null) + idle_config = v.orchestration_provider.webhook.lambda.scale.down.idle_config != null ? v.orchestration_provider.webhook.lambda.scale.down.idle_config : local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.down.idle_config + tags = merge(local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.down.tags, v.orchestration_provider.webhook.lambda.scale.down.tags) }) }) - pool = merge(v.orchestration.webhook.lambda.pool, { - memory_size = coalesce(v.orchestration.webhook.lambda.pool.memory_size, local.raw_translated_experimental.orchestration.webhook.lambda.pool.memory_size) - timeout = coalesce(v.orchestration.webhook.lambda.pool.timeout, local.raw_translated_experimental.orchestration.webhook.lambda.pool.timeout) - reserved_concurrent_executions = coalesce(v.orchestration.webhook.lambda.pool.reserved_concurrent_executions, local.raw_translated_experimental.orchestration.webhook.lambda.pool.reserved_concurrent_executions) - config = v.orchestration.webhook.lambda.pool.config != null ? v.orchestration.webhook.lambda.pool.config : local.raw_translated_experimental.orchestration.webhook.lambda.pool.config - include_busy_runners = coalesce(v.orchestration.webhook.lambda.pool.include_busy_runners, local.raw_translated_experimental.orchestration.webhook.lambda.pool.include_busy_runners) - runner_owner = try(coalesce(v.orchestration.webhook.lambda.pool.runner_owner, local.raw_translated_experimental.orchestration.webhook.lambda.pool.runner_owner), null) - tags = merge(local.raw_translated_experimental.orchestration.webhook.lambda.pool.tags, v.orchestration.webhook.lambda.pool.tags) + pool = merge(v.orchestration_provider.webhook.lambda.pool, { + memory_size = coalesce(v.orchestration_provider.webhook.lambda.pool.memory_size, local.raw_translated_experimental.orchestration_provider.webhook.lambda.pool.memory_size) + timeout = coalesce(v.orchestration_provider.webhook.lambda.pool.timeout, local.raw_translated_experimental.orchestration_provider.webhook.lambda.pool.timeout) + reserved_concurrent_executions = coalesce(v.orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions, local.raw_translated_experimental.orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions) + config = v.orchestration_provider.webhook.lambda.pool.config != null ? v.orchestration_provider.webhook.lambda.pool.config : local.raw_translated_experimental.orchestration_provider.webhook.lambda.pool.config + include_busy_runners = coalesce(v.orchestration_provider.webhook.lambda.pool.include_busy_runners, local.raw_translated_experimental.orchestration_provider.webhook.lambda.pool.include_busy_runners) + runner_owner = try(coalesce(v.orchestration_provider.webhook.lambda.pool.runner_owner, local.raw_translated_experimental.orchestration_provider.webhook.lambda.pool.runner_owner), null) + tags = merge(local.raw_translated_experimental.orchestration_provider.webhook.lambda.pool.tags, v.orchestration_provider.webhook.lambda.pool.tags) }) }) - queue = merge(v.orchestration.webhook.queue, { - delay_webhook_event = coalesce(v.orchestration.webhook.queue.delay_webhook_event, local.raw_translated_experimental.orchestration.webhook.queue.delay_webhook_event) - job_queue_retention_in_seconds = coalesce(v.orchestration.webhook.queue.job_queue_retention_in_seconds, local.raw_translated_experimental.orchestration.webhook.queue.job_queue_retention_in_seconds) - visibility_timeout_seconds = coalesce(v.orchestration.webhook.queue.visibility_timeout_seconds, local.raw_translated_experimental.orchestration.webhook.queue.visibility_timeout_seconds) + queue = merge(v.orchestration_provider.webhook.queue, { + delay_webhook_event = coalesce(v.orchestration_provider.webhook.queue.delay_webhook_event, local.raw_translated_experimental.orchestration_provider.webhook.queue.delay_webhook_event) + job_queue_retention_in_seconds = coalesce(v.orchestration_provider.webhook.queue.job_queue_retention_in_seconds, local.raw_translated_experimental.orchestration_provider.webhook.queue.job_queue_retention_in_seconds) + visibility_timeout_seconds = coalesce(v.orchestration_provider.webhook.queue.visibility_timeout_seconds, local.raw_translated_experimental.orchestration_provider.webhook.queue.visibility_timeout_seconds) redrive_build_queue = { enabled = try( - coalesce(try(v.orchestration.webhook.queue.redrive_build_queue.enabled, null), local.raw_translated_experimental.orchestration.webhook.queue.redrive_build_queue.enabled), - local.raw_translated_experimental.orchestration.webhook.queue.redrive_build_queue.enabled, + coalesce(try(v.orchestration_provider.webhook.queue.redrive_build_queue.enabled, null), local.raw_translated_experimental.orchestration_provider.webhook.queue.redrive_build_queue.enabled), + local.raw_translated_experimental.orchestration_provider.webhook.queue.redrive_build_queue.enabled, ) maxReceiveCount = try( - coalesce(try(v.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount, null), local.raw_translated_experimental.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount), + coalesce(try(v.orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount, null), local.raw_translated_experimental.orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount), null, ) } - tags = merge(local.raw_translated_experimental.orchestration.webhook.queue.tags, v.orchestration.webhook.queue.tags) + tags = merge(local.raw_translated_experimental.orchestration_provider.webhook.queue.tags, v.orchestration_provider.webhook.queue.tags) }) }) } @@ -774,7 +774,7 @@ locals { v.runner.os, v.runner.architecture, ]), - v.orchestration.webhook == null ? [] : flatten(v.orchestration.webhook.matcherConfig.labelMatchers), + v.orchestration_provider.webhook == null ? [] : flatten(v.orchestration_provider.webhook.matcherConfig.labelMatchers), compact(v.runner.extra_labels), )) }) @@ -789,14 +789,14 @@ locals { principals = local.translated_experimental_base.lambda.principals }) - orchestration = { - webhook = v.orchestration.webhook == null ? null : merge(v.orchestration.webhook, { - queue = merge(v.orchestration.webhook.queue, { - kms_key_id = local.translated_experimental_base.orchestration.webhook.queue.encryption.kms_master_key_id + orchestration_provider = { + webhook = v.orchestration_provider.webhook == null ? null : merge(v.orchestration_provider.webhook, { + queue = merge(v.orchestration_provider.webhook.queue, { + kms_key_id = local.translated_experimental_base.orchestration_provider.webhook.queue.encryption.kms_master_key_id }) - lambda = merge(v.orchestration.webhook.lambda, { - artifact = local.translated_experimental_base.orchestration.webhook.lambda.artifact + lambda = merge(v.orchestration_provider.webhook.lambda, { + artifact = local.translated_experimental_base.orchestration_provider.webhook.lambda.artifact }) }) } diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 9576a45c68..212988dc72 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -23,14 +23,14 @@ output "runners_map" { } output "runners_map_v2" { - description = "Experimental v2 runner resources keyed by runner configuration. The orchestration object is canonical; scale_up, scale_down, and pool remain compatibility aliases." + description = "Experimental v2 runner resources keyed by runner configuration. The `orchestration_provider` object is canonical; `scale_up`, `scale_down`, and `pool` remain compatibility aliases." value = { for runner_key, runner in module.runner_configs : runner_key => { - runner = runner.runner - orchestration = runner.orchestration - scale_up = runner.scale_up - scale_down = runner.scale_down - pool = runner.pool - provider = runner.provider + runner = runner.runner + orchestration_provider = runner.orchestration_provider + scale_up = runner.scale_up + scale_down = runner.scale_down + pool = runner.pool + provider = runner.provider } } } @@ -53,8 +53,8 @@ output "webhook" { lambda_role = module.webhook.role endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}" webhook = module.webhook.webhook - dispatcher = local.translated_experimental.orchestration.webhook.eventbridge.enable ? module.webhook.dispatcher : null - eventbridge = local.translated_experimental.orchestration.webhook.eventbridge.enable ? module.webhook.eventbridge : null + dispatcher = local.translated_experimental.orchestration_provider.webhook.eventbridge.enable ? module.webhook.dispatcher : null + eventbridge = local.translated_experimental.orchestration_provider.webhook.eventbridge.enable ? module.webhook.eventbridge : null } } diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index f3d49e9448..2b27d9df40 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -28,23 +28,23 @@ data "aws_iam_policy_document" "deny_insecure_transport_build" { resource "aws_sqs_queue" "queued_builds" { for_each = local.webhook_runner_config name = "${var.prefix}-${each.key}-queued-builds" - delay_seconds = each.value.orchestration.webhook.queue.delay_webhook_event - visibility_timeout_seconds = each.value.orchestration.webhook.queue.visibility_timeout_seconds - message_retention_seconds = each.value.orchestration.webhook.queue.job_queue_retention_in_seconds + delay_seconds = each.value.orchestration_provider.webhook.queue.delay_webhook_event + visibility_timeout_seconds = each.value.orchestration_provider.webhook.queue.visibility_timeout_seconds + message_retention_seconds = each.value.orchestration_provider.webhook.queue.job_queue_retention_in_seconds receive_wait_time_seconds = 0 - redrive_policy = each.value.orchestration.webhook.queue.redrive_build_queue.enabled ? jsonencode({ + redrive_policy = each.value.orchestration_provider.webhook.queue.redrive_build_queue.enabled ? jsonencode({ deadLetterTargetArn = aws_sqs_queue.queued_builds_dlq[each.key].arn, - maxReceiveCount = each.value.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount + maxReceiveCount = each.value.orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount }) : null - sqs_managed_sse_enabled = local.translated_experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled - kms_master_key_id = local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id - kms_data_key_reuse_period_seconds = local.translated_experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds + sqs_managed_sse_enabled = local.translated_experimental.orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled + kms_master_key_id = local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds tags = merge( local.translated_experimental.tags, each.value.tags, - each.value.orchestration.webhook.queue.tags, + each.value.orchestration_provider.webhook.queue.tags, ) } resource "aws_sqs_queue_policy" "build_queue_policy" { @@ -54,21 +54,21 @@ resource "aws_sqs_queue_policy" "build_queue_policy" { } resource "aws_sqs_queue" "queued_builds_dlq" { - for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration.webhook.queue.redrive_build_queue.enabled } + for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled } name = "${var.prefix}-${each.key}-queued-builds_dead_letter" - sqs_managed_sse_enabled = local.translated_experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled - kms_master_key_id = local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id - kms_data_key_reuse_period_seconds = local.translated_experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds + sqs_managed_sse_enabled = local.translated_experimental.orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled + kms_master_key_id = local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds tags = merge( local.translated_experimental.tags, each.value.tags, - each.value.orchestration.webhook.queue.tags, + each.value.orchestration_provider.webhook.queue.tags, ) } data "aws_iam_policy_document" "deny_insecure_transport_build_dlq" { - for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration.webhook.queue.redrive_build_queue.enabled } + for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled } statement { sid = "DenyInsecureTransport" @@ -95,7 +95,7 @@ data "aws_iam_policy_document" "deny_insecure_transport_build_dlq" { } resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { - for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration.webhook.queue.redrive_build_queue.enabled } + for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled } queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id policy = data.aws_iam_policy_document.deny_insecure_transport_build_dlq[each.key].json } diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index 4ddaae2dd4..25108dcda8 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -18,18 +18,18 @@ module "runner_configs" { app_parameters = local.github_app_parameters }) lambda = each.value.lambda - orchestration = { - webhook = each.value.orchestration.webhook == null ? null : { - runner = each.value.orchestration.webhook.runner - github = each.value.orchestration.webhook.github - queue = merge(each.value.orchestration.webhook.queue, { + orchestration_provider = { + webhook = each.value.orchestration_provider.webhook == null ? null : { + runner = each.value.orchestration_provider.webhook.runner + github = each.value.orchestration_provider.webhook.github + queue = merge(each.value.orchestration_provider.webhook.queue, { build = { arn = aws_sqs_queue.queued_builds[each.key].arn url = aws_sqs_queue.queued_builds[each.key].url } }) - lambda = each.value.orchestration.webhook.lambda - job_retry = each.value.orchestration.webhook.job_retry + lambda = each.value.orchestration_provider.webhook.lambda + job_retry = each.value.orchestration_provider.webhook.job_retry } } ssm = each.value.ssm diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index f1c0c9d68c..5b5898b8e2 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -42,22 +42,22 @@ module "runners" { 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.orchestration.webhook.github.organization_runners - enable_ephemeral_runners = each.value.orchestration.webhook.runner.ephemeral - enable_jit_config = each.value.orchestration.webhook.runner.jit_config_enabled - enable_job_queued_check = each.value.orchestration.webhook.lambda.scale.up.job_queued_check_enabled + enable_organization_runners = each.value.orchestration_provider.webhook.github.organization_runners + enable_ephemeral_runners = each.value.orchestration_provider.webhook.runner.ephemeral + enable_jit_config = each.value.orchestration_provider.webhook.runner.jit_config_enabled + enable_job_queued_check = each.value.orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled disable_runner_autoupdate = each.value.runner.auto_update_disabled enable_managed_runner_security_group = each.value.compute_provider.ec2.managed_security_group_enabled enable_runner_detailed_monitoring = each.value.compute_provider.ec2.detailed_monitoring_enabled - scale_down_schedule_expression = each.value.orchestration.webhook.lambda.scale.down.schedule_expression - minimum_running_time_in_minutes = each.value.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes - runner_boot_time_in_minutes = each.value.orchestration.webhook.runner.boot_time_in_minutes + scale_down_schedule_expression = each.value.orchestration_provider.webhook.lambda.scale.down.schedule_expression + minimum_running_time_in_minutes = each.value.orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes + runner_boot_time_in_minutes = each.value.orchestration_provider.webhook.runner.boot_time_in_minutes runner_disable_default_labels = each.value.runner.disable_default_labels runner_labels = each.value.runner.labels runner_as_root = each.value.runner.run_as_root runner_run_as = each.value.runner.run_as - runners_maximum_count = each.value.orchestration.webhook.runner.maximum_count - idle_config = each.value.orchestration.webhook.lambda.scale.down.idle_config + runners_maximum_count = each.value.orchestration_provider.webhook.runner.maximum_count + idle_config = each.value.orchestration_provider.webhook.lambda.scale.down.idle_config enable_ssm_on_runners = each.value.compute_provider.ec2.ssm_enabled egress_rules = each.value.compute_provider.ec2.egress_rules runner_additional_security_group_ids = each.value.compute_provider.ec2.additional_security_group_ids @@ -69,18 +69,18 @@ module "runners" { 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 = local.translated_experimental.orchestration.webhook.lambda.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket - runners_lambda_s3_key = try(local.translated_experimental.orchestration.webhook.lambda.artifact.s3.key, null) - runners_lambda_s3_object_version = try(local.translated_experimental.orchestration.webhook.lambda.artifact.s3.object_version, null) + lambda_s3_bucket = local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + runners_lambda_s3_key = try(local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.key, null) + runners_lambda_s3_object_version = try(local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.object_version, null) lambda_runtime = each.value.lambda.runtime lambda_architecture = each.value.lambda.architecture - lambda_zip = local.translated_experimental.orchestration.webhook.lambda.artifact.zip - lambda_scale_up_memory_size = each.value.orchestration.webhook.lambda.scale.up.memory_size - lambda_event_source_mapping_batch_size = each.value.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = each.value.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds - lambda_timeout_scale_up = each.value.orchestration.webhook.lambda.scale.up.timeout - lambda_scale_down_memory_size = each.value.orchestration.webhook.lambda.scale.down.memory_size - lambda_timeout_scale_down = each.value.orchestration.webhook.lambda.scale.down.timeout + lambda_zip = local.translated_experimental.orchestration_provider.webhook.lambda.artifact.zip + lambda_scale_up_memory_size = each.value.orchestration_provider.webhook.lambda.scale.up.memory_size + lambda_event_source_mapping_batch_size = each.value.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = each.value.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds + lambda_timeout_scale_up = each.value.orchestration_provider.webhook.lambda.scale.up.timeout + lambda_scale_down_memory_size = each.value.orchestration_provider.webhook.lambda.scale.down.memory_size + lambda_timeout_scale_down = each.value.orchestration_provider.webhook.lambda.scale.down.timeout lambda_subnet_ids = each.value.lambda.subnet_ids lambda_security_group_ids = each.value.lambda.security_group_ids lambda_tags = each.value.lambda.tags @@ -95,7 +95,7 @@ module "runners" { runner_name_prefix = each.value.runner.name_prefix parameter_store_tags = each.value.ssm.parameters.tags - scale_up_reserved_concurrent_executions = each.value.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions + scale_up_reserved_concurrent_executions = each.value.orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions instance_profile_path = each.value.compute_provider.ec2.instance_profile_path role_path = each.value.runner.iam.path @@ -130,12 +130,12 @@ module "runners" { log_level = each.value.observability.logs.level - pool_config = each.value.orchestration.webhook.lambda.pool.config - pool_lambda_timeout = each.value.orchestration.webhook.lambda.pool.timeout - pool_lambda_memory_size = each.value.orchestration.webhook.lambda.pool.memory_size - pool_runner_owner = each.value.orchestration.webhook.lambda.pool.runner_owner - pool_include_busy_runners = each.value.orchestration.webhook.lambda.pool.include_busy_runners - pool_lambda_reserved_concurrent_executions = each.value.orchestration.webhook.lambda.pool.reserved_concurrent_executions + pool_config = each.value.orchestration_provider.webhook.lambda.pool.config + pool_lambda_timeout = each.value.orchestration_provider.webhook.lambda.pool.timeout + pool_lambda_memory_size = each.value.orchestration_provider.webhook.lambda.pool.memory_size + pool_runner_owner = each.value.orchestration_provider.webhook.lambda.pool.runner_owner + pool_include_busy_runners = each.value.orchestration_provider.webhook.lambda.pool.include_busy_runners + pool_lambda_reserved_concurrent_executions = each.value.orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions associate_public_ipv4_address = each.value.compute_provider.ec2.associate_public_ipv4_address ssm_housekeeper = { @@ -147,13 +147,13 @@ module "runners" { } job_retry = { - enable = each.value.orchestration.webhook.job_retry.enabled - delay_in_seconds = each.value.orchestration.webhook.job_retry.delay_in_seconds - delay_backoff = each.value.orchestration.webhook.job_retry.delay_backoff - lambda_memory_size = each.value.orchestration.webhook.job_retry.lambda.memory_size - lambda_reserved_concurrent_executions = each.value.orchestration.webhook.job_retry.lambda.reserved_concurrent_executions - lambda_timeout = each.value.orchestration.webhook.job_retry.lambda.timeout - max_attempts = each.value.orchestration.webhook.job_retry.max_attempts + enable = each.value.orchestration_provider.webhook.job_retry.enabled + delay_in_seconds = each.value.orchestration_provider.webhook.job_retry.delay_in_seconds + delay_backoff = each.value.orchestration_provider.webhook.job_retry.delay_backoff + lambda_memory_size = each.value.orchestration_provider.webhook.job_retry.lambda.memory_size + lambda_reserved_concurrent_executions = each.value.orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions + lambda_timeout = each.value.orchestration_provider.webhook.job_retry.lambda.timeout + max_attempts = each.value.orchestration_provider.webhook.job_retry.max_attempts } metrics = { diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf index 68635ece1e..887731e3ba 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -41,7 +41,7 @@ module "multi_runner" { } } - orchestration = { + orchestration_provider = { webhook = { queue = { encryption = { @@ -100,7 +100,7 @@ module "multi_runner" { } } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 diff --git a/modules/multi-runner/tests/provider-routing-v1.tftest.hcl b/modules/multi-runner/tests/provider-routing-v1.tftest.hcl new file mode 100644 index 0000000000..ef0361795f --- /dev/null +++ b/modules/multi-runner/tests/provider-routing-v1.tftest.hcl @@ -0,0 +1,815 @@ +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\"}]}" + } + } +} +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_zip = "README.md" + runners_lambda_s3_key = "runners.zip" + 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." + } + + assert { + condition = ( + length(local.raw_translated_experimental.multi_runner_config) == 0 + && length(local.translated_experimental.multi_runner_config) == 0 + && length(local.webhook_runner_config) == 0 + && length(local.runner_matcher_config) == 0 + && length(module.runner_configs) == 0 + ) + error_message = "An empty stable and experimental configuration must translate to an empty raw runner-configuration map without selecting a v2 runner configuration." + } + + assert { + condition = ( + local.github_app_parameters.webhook_secret != null + && module.ssm.parameters.github_app_webhook_secret != null + && output.ssm_parameters.webhook_secret != null + && output.webhook != null + ) + error_message = "Stable v1 must retain its shared webhook and webhook-secret parameter even when multi_runner_config is empty." + } +} + +run "stable_v1_keeps_legacy_runner_module" { + command = plan + + variables { + tags = { + StableGlobal = "global" + Precedence = "global" + } + + repository_white_list = ["legacy-owner/legacy-repository"] + queue_selection_strategy = "random" + eventbridge = { + enable = false + accept_events = ["workflow_job"] + } + matcher_config_parameter_store_tier = "Advanced" + webhook_lambda_apigateway_access_log_settings = { + destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:legacy-api-access" + format = "$context.requestId" + } + webhook_lambda_s3_object_version = "legacy-webhook-version" + + lambda_runtime = "nodejs20.x" + lambda_architecture = "x86_64" + lambda_subnet_ids = ["subnet-legacy-lambda"] + lambda_security_group_ids = ["sg-legacy-lambda"] + lambda_principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:role/legacy-lambda-principal"] + }] + webhook_lambda_memory_size = 320 + webhook_lambda_timeout = 25 + runners_scale_up_lambda_timeout = 47 + runners_lambda_s3_object_version = "legacy-runners-version" + runner_binaries_syncer_memory_size = 640 + runner_binaries_syncer_lambda_timeout = 70 + role_path = "/legacy/" + role_permissions_boundary = "arn:aws:iam::123456789012:policy/legacy-boundary" + ghes_url = "https://legacy.example.com" + ghes_ssl_verify = false + user_agent = "legacy-user-agent" + log_level = "warn" + logging_retention_in_days = 14 + logging_kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + log_class = "STANDARD" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" + + queue_encryption = { + kms_data_key_reuse_period_seconds = 300 + kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/legacy-queue" + sqs_managed_sse_enabled = null + } + + lambda_tags = { + LegacyLambda = "legacy" + } + + tracing_config = { + mode = "Active" + capture_http_requests = true + capture_error = false + } + + metrics = { + enable = true + namespace = "LegacyMetrics" + metric = { + enable_github_app_rate_limit = true + enable_job_retry = false + enable_spot_termination_warning = false + } + } + + ssm_paths = { + root = "legacy-root" + app = "legacy-app" + runners = "legacy-runners" + webhook = "legacy-webhook" + } + + parameter_store_tags = { + LegacyParameter = "legacy" + Precedence = "legacy-parameter" + } + + runners_ssm_housekeeper = { + schedule_expression = "rate(12 hours)" + enabled = false + lambda_memory_size = 320 + lambda_timeout = 45 + config = { + tokenPath = "/legacy/cleanup/tokens" + minimumDaysOld = 5 + dryRun = true + } + } + + instance_termination_watcher = { + enable = true + enable_runner_deregistration = true + environment_variables = { + LEGACY_WATCHER = "true" + } + features = { + enable_spot_termination_handler = true + enable_spot_termination_notification_watcher = true + } + memory_size = 448 + timeout = 35 + s3_key = "termination-watcher.zip" + s3_object_version = "legacy-watcher-version" + } + + enable_ami_housekeeper = true + ami_housekeeper_lambda_memory_size = 384 + ami_housekeeper_lambda_timeout = 90 + ami_housekeeper_lambda_s3_key = "ami-housekeeper.zip" + ami_housekeeper_lambda_s3_object_version = "legacy-ami-housekeeper-version" + ami_housekeeper_lambda_schedule_expression = "rate(2 days)" + ami_housekeeper_cleanup_config = { + maxItems = 7 + minimumDaysOld = 14 + dryRun = true + } + + experimental = { + tags = { + ExperimentalOnly = "ignored" + } + roles = { + path = "/experimental/" + } + lambda = { + artifact = { + s3 = { + bucket = "experimental-ignored-artifacts" + } + } + runtime = "nodejs22.x" + architecture = "sparc64" + principals = [{ + type = "AWS" + identifiers = ["arn:aws:iam::123456789012:role/experimental-ignored-principal"] + }] + subnet_ids = ["subnet-experimental-lambda"] + security_group_ids = ["sg-experimental-lambda"] + tags = { + ExperimentalLambda = "ignored" + } + } + + orchestration_provider = { + webhook = { + lambda = { + webhook = { + memory_size = 896 + timeout = 90 + } + } + } + } + github = { + app = { + id = "incomplete-experimental-id" + } + additional_apps = [{ id = "incomplete-additional-app-id" }] + enterprise_server = { + url = "https://experimental.example.com" + ssl_verify = true + } + user_agent = "experimental-user-agent" + } + ssm = { + paths = { + root = "relative-experimental-root" + tokens = "experimental-tokens" + config = "experimental-config" + } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-ssm" + tags = { + ExperimentalSsm = "ignored" + } + parameters = { + tags = { + ExperimentalParameter = "ignored" + } + } + housekeeper = { + schedule_expression = "rate(1 hour)" + state = "PAUSED" + lambda = { + memory_size = 896 + timeout = 90 + } + config = { + tokenPath = "/experimental/cleanup/tokens" + minimumDaysOld = 1 + dryRun = false + } + } + } + observability = { + logs = { + level = "verbose" + retention_in_days = 30 + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-logs" + class = "ARCHIVE" + } + tracing = { + mode = "PassThrough" + capture_http_requests = false + capture_error = true + } + metrics = { + enable = false + namespace = "ExperimentalMetrics" + metric = { + enable_github_app_rate_limit = false + enable_job_retry = true + enable_spot_termination_warning = true + } + } + } + } + + multi_runner_config = { + linux = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 2 + enable_runner_binaries_syncer = true + enable_organization_runners = true + delay_webhook_event = 17 + job_queue_retention_in_seconds = 12345 + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + redrive_build_queue = { + enabled = true + maxReceiveCount = 3 + } + } + } + } + + assert { + condition = ( + toset(keys(local.raw_translated_experimental)) == toset([ + "tags", + "roles", + "runner", + "github", + "lambda", + "orchestration_provider", + "ssm", + "observability", + "compute_provider", + "multi_runner_config", + ]) + && toset(keys(local.raw_translated_experimental.github)) == toset([ + "app", + "additional_apps", + "enterprise_server", + "user_agent", + ]) + && toset(keys(local.raw_translated_experimental.lambda)) == toset([ + "artifact", + "runtime", + "architecture", + "principals", + "subnet_ids", + "security_group_ids", + "tags", + "role", + ]) + && toset(keys(local.raw_translated_experimental.orchestration_provider)) == toset([ + "webhook", + ]) + && toset(keys(local.raw_translated_experimental.orchestration_provider.webhook)) == toset([ + "queue_selection_strategy", + "eventbridge", + "matcher_config_parameter_store_tier", + "runner", + "github", + "lambda", + "queue", + ]) + && toset(keys(local.raw_translated_experimental.orchestration_provider.webhook.runner)) == toset([ + "boot_time_in_minutes", + "ephemeral", + "jit_config_enabled", + "maximum_count", + ]) + && toset(keys(local.raw_translated_experimental.orchestration_provider.webhook.github)) == toset([ + "repository_white_list", + ]) + && toset(keys(local.raw_translated_experimental.orchestration_provider.webhook.lambda)) == toset([ + "artifact", + "scale", + "webhook", + "pool", + ]) + && toset(keys(local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale)) == toset([ + "up", + "down", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"])) == toset([ + "tags", + "runner", + "lambda", + "orchestration_provider", + "ssm", + "observability", + "compute_provider", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].lambda)) == toset([ + "runtime", + "architecture", + "subnet_ids", + "security_group_ids", + "tags", + "role", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider)) == toset([ + "webhook", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook)) == toset([ + "runner", + "github", + "lambda", + "queue", + "job_retry", + "matcherConfig", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda)) == toset([ + "scale", + "pool", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale)) == toset([ + "up", + "down", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue)) == toset([ + "delay_webhook_event", + "job_queue_retention_in_seconds", + "visibility_timeout_seconds", + "redrive_build_queue", + "tags", + ]) + && toset(keys(local.raw_translated_experimental.orchestration_provider.webhook.queue)) == toset([ + "delay_webhook_event", + "job_queue_retention_in_seconds", + "visibility_timeout_seconds", + "redrive_build_queue", + "tags", + "encryption", + ]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["ec2"]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].ssm), "kms_key_id") + && !contains(keys(local.raw_translated_experimental.runner), "boot_time_in_minutes") + && !contains(keys(local.raw_translated_experimental.runner), "ephemeral") + && !contains(keys(local.raw_translated_experimental.runner), "jit_config_enabled") + && !contains(keys(local.raw_translated_experimental.runner), "maximum_count") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "ephemeral") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "maximum_count") + && local.raw_translated_experimental.orchestration_provider.webhook.runner.boot_time_in_minutes == 5 + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"]), "scale_up") + ) + error_message = "Stable v1 must translate into the exact raw experimental schema before defaults, queue event-source mappings, binary artifacts, or runner-config component shapes are resolved." + } + + assert { + condition = ( + toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled + && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3)) == toset(["arn", "id", "key"]) + ) + error_message = "Stable translation must discover binary-syncer runner configurations from the base object, then enrich the final canonical configuration with its resolved S3 distribution." + } + + assert { + condition = ( + local.raw_translated_experimental.tags == var.tags + && local.raw_translated_experimental.roles.path == var.role_path + && local.raw_translated_experimental.roles.permissions_boundary == var.role_permissions_boundary + && local.raw_translated_experimental.github.app == var.github_app + && local.raw_translated_experimental.github.additional_apps == var.additional_github_apps + && local.raw_translated_experimental.orchestration_provider.webhook.github.repository_white_list == var.repository_white_list + && local.raw_translated_experimental.github.enterprise_server.url == var.ghes_url + && local.raw_translated_experimental.github.enterprise_server.ssl_verify == var.ghes_ssl_verify + && local.raw_translated_experimental.github.user_agent == var.user_agent + && local.raw_translated_experimental.orchestration_provider.webhook.queue_selection_strategy == var.queue_selection_strategy + && local.raw_translated_experimental.orchestration_provider.webhook.eventbridge == var.eventbridge + && local.raw_translated_experimental.orchestration_provider.webhook.matcher_config_parameter_store_tier == var.matcher_config_parameter_store_tier + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.artifact.zip == null + && local.raw_translated_experimental.lambda.artifact.s3.bucket == var.lambda_s3_bucket + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.key == var.runners_lambda_s3_key + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version + && local.raw_translated_experimental.lambda.runtime == var.lambda_runtime + && local.raw_translated_experimental.lambda.architecture == var.lambda_architecture + && local.raw_translated_experimental.lambda.principals == var.lambda_principals + && local.raw_translated_experimental.lambda.subnet_ids == var.lambda_subnet_ids + && local.raw_translated_experimental.lambda.security_group_ids == var.lambda_security_group_ids + && local.raw_translated_experimental.lambda.tags == var.lambda_tags + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == var.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == var.lambda_event_source_mapping_maximum_batching_window_in_seconds + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.zip == null + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3.key == var.webhook_lambda_s3_key + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version == var.webhook_lambda_s3_object_version + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings == var.webhook_lambda_apigateway_access_log_settings + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.webhook.memory_size == var.webhook_lambda_memory_size + && local.raw_translated_experimental.orchestration_provider.webhook.lambda.webhook.timeout == var.webhook_lambda_timeout + && local.raw_translated_experimental.orchestration_provider.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && local.raw_translated_experimental.orchestration_provider.webhook.queue.encryption == var.queue_encryption + && local.raw_translated_experimental.ssm.paths.root == "/legacy-root/github-actions" + && local.raw_translated_experimental.ssm.paths.app == var.ssm_paths.app + && local.raw_translated_experimental.ssm.paths.webhook == var.ssm_paths.webhook + && local.raw_translated_experimental.ssm.paths.tokens == "${var.ssm_paths.runners}/tokens" + && local.raw_translated_experimental.ssm.paths.config == "${var.ssm_paths.runners}/config" + && local.raw_translated_experimental.ssm.kms_key_id == var.kms_key_arn + && local.raw_translated_experimental.ssm.parameters.tags == var.parameter_store_tags + && local.raw_translated_experimental.observability.logs.level == var.log_level + && local.raw_translated_experimental.observability.logs.retention_in_days == var.logging_retention_in_days + && local.raw_translated_experimental.observability.logs.kms_key_id == var.logging_kms_key_id + && local.raw_translated_experimental.observability.logs.class == var.log_class + && local.raw_translated_experimental.observability.tracing == var.tracing_config + && local.raw_translated_experimental.observability.metrics.enable == var.metrics.enable + && local.raw_translated_experimental.observability.metrics.namespace == var.metrics.namespace + && local.raw_translated_experimental.observability.metrics.metric.enable_github_app_rate_limit == var.metrics.metric.enable_github_app_rate_limit + && local.raw_translated_experimental.observability.metrics.metric.enable_job_retry == var.metrics.metric.enable_job_retry + && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination + && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination_warning == var.metrics.metric.enable_spot_termination_warning + && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.zip == null + && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3.key == var.runners_lambda_s3_key + && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.key == var.runners_lambda_s3_key + && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version + && local.raw_translated_experimental.compute_provider.ec2.vpc_id == var.vpc_id + && local.raw_translated_experimental.compute_provider.ec2.subnet_ids == var.subnet_ids + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.enabled == var.enable_ami_housekeeper + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.cleanup_config == var.ami_housekeeper_cleanup_config + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.zip == null + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key == var.ami_housekeeper_lambda_s3_key + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.object_version == var.ami_housekeeper_lambda_s3_object_version + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.memory_size == var.ami_housekeeper_lambda_memory_size + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.timeout == var.ami_housekeeper_lambda_timeout + && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.schedule.expression == var.ami_housekeeper_lambda_schedule_expression + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled == var.instance_termination_watcher.enable + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.features == var.instance_termination_watcher.features + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration == var.instance_termination_watcher.enable_runner_deregistration + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables == var.instance_termination_watcher.environment_variables + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip == null + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key == var.instance_termination_watcher.s3_key + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version == var.instance_termination_watcher.s3_object_version + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.memory_size == var.instance_termination_watcher.memory_size + && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.timeout == var.instance_termination_watcher.timeout + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.enabled + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm == var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.tags == var.runner_binaries_s3_tags + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == var.runner_binaries_s3_versioning + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key == var.syncer_lambda_s3_key + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version == var.syncer_lambda_s3_object_version + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size == var.runner_binaries_syncer_memory_size + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.timeout == var.runner_binaries_syncer_lambda_timeout + && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == var.state_event_rule_binaries_syncer + && local.raw_translated_experimental.multi_runner_config["linux"].runner.os == var.multi_runner_config["linux"].runner_config.runner_os + && local.raw_translated_experimental.multi_runner_config["linux"].runner.architecture == var.multi_runner_config["linux"].runner_config.runner_architecture + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "ephemeral") + && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.boot_time_in_minutes == var.multi_runner_config["linux"].runner_config.runner_boot_time_in_minutes + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.ephemeral == var.multi_runner_config["linux"].runner_config.enable_ephemeral_runners + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.jit_config_enabled == var.multi_runner_config["linux"].runner_config.enable_jit_config + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.maximum_count == var.multi_runner_config["linux"].runner_config.runners_maximum_count + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.github.organization_runners == var.multi_runner_config["linux"].runner_config.enable_organization_runners + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == var.multi_runner_config["linux"].runner_config.lambda_event_source_mapping_batch_size + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.delay_webhook_event == var.multi_runner_config["linux"].runner_config.delay_webhook_event + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.job_queue_retention_in_seconds == var.multi_runner_config["linux"].runner_config.job_queue_retention_in_seconds + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.redrive_build_queue == var.multi_runner_config["linux"].redrive_build_queue + && local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled + && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.matcherConfig == var.multi_runner_config["linux"].matcherConfig + ) + error_message = "Stable v1 flat and per-runner inputs must populate every raw translation family while conflicting experimental globals remain inactive." + } + + assert { + condition = ( + !var.runners_ssm_housekeeper.enabled + && local.translated_experimental.ssm.housekeeper.state == "DISABLED" + && keys(module.runners) == ["linux"] + ) + error_message = "Stable v1 must translate runners_ssm_housekeeper.enabled=false to the DISABLED child event-rule state while retaining module.runners ownership." + } + + assert { + condition = keys(local.runner_config_by_provider.ec2) == ["linux"] + error_message = "Stable multi_runner_config entries must route to the EC2 provider." + } + + assert { + condition = ( + !local.use_multi_runner_config_v2 + && toset(keys(local.raw_translated_experimental.multi_runner_config)) == toset(["linux"]) + && toset(keys(local.translated_experimental.multi_runner_config)) == toset(["linux"]) + && length(module.runner_configs) == 0 + && keys(module.runners) == ["linux"] + ) + error_message = "Stable multi_runner_config entries must keep the original runner configuration and remain isolated from v2." + } + + assert { + condition = ( + var.experimental.github.app.key_base64 == null + && var.experimental.github.app.webhook_secret == null + && var.experimental.github.additional_apps[0].key_base64 == null + && var.experimental.lambda.architecture == "sparc64" + && var.experimental.observability.logs.level == "verbose" + && var.experimental.observability.logs.class == "ARCHIVE" + && var.experimental.ssm.paths.root == "relative-experimental-root" + && var.experimental.ssm.housekeeper.state == "PAUSED" + && !local.use_multi_runner_config_v2 + && keys(module.runners) == ["linux"] + ) + error_message = "Invalid but unused experimental sibling globals must remain gated when a stable v1 configuration owns the deployment." + } + + assert { + condition = ( + contains(keys(local.translated_experimental.multi_runner_config["linux"]), "compute_provider") + && !contains(keys(local.translated_experimental.multi_runner_config["linux"]), "runner_config") + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.github.organization_runners + ) + error_message = "Stable module inputs must use the canonical translated runner configuration while retaining stable module.runners ownership." + } + + assert { + condition = keys(module.runners) == ["linux"] && length(module.runner_configs) == 0 + 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 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 { + condition = ( + aws_sqs_queue.queued_builds["linux"].delay_seconds == 17 + && aws_sqs_queue.queued_builds["linux"].message_retention_seconds == 12345 + && aws_sqs_queue.queued_builds["linux"].visibility_timeout_seconds == var.runners_scale_up_lambda_timeout + && aws_sqs_queue.queued_builds["linux"].kms_master_key_id == var.queue_encryption.kms_master_key_id + && aws_sqs_queue.queued_builds["linux"].kms_data_key_reuse_period_seconds == var.queue_encryption.kms_data_key_reuse_period_seconds + && aws_sqs_queue.queued_builds_dlq["linux"].kms_master_key_id == var.queue_encryption.kms_master_key_id + && aws_sqs_queue.queued_builds_dlq["linux"].kms_data_key_reuse_period_seconds == var.queue_encryption.kms_data_key_reuse_period_seconds + ) + error_message = "Stable v1 queues must retain per-runner delay and retention plus flat timeout and encryption inputs after translation." + } + + assert { + condition = ( + output.runners_map["linux"].lambda_up.runtime == "nodejs20.x" + && output.runners_map["linux"].lambda_up.s3_bucket == var.lambda_s3_bucket + && output.runners_map["linux"].lambda_up.s3_key == var.runners_lambda_s3_key + && output.runners_map["linux"].lambda_up.s3_object_version == "legacy-runners-version" + && output.runners_map["linux"].role_scale_up.path == "/legacy/" + && output.webhook.lambda.runtime == "nodejs20.x" + && output.webhook.lambda.architectures == tolist(["x86_64"]) + && output.webhook.lambda.memory_size == 320 + && output.webhook.lambda.timeout == 25 + && output.webhook.lambda.s3_bucket == "lambda-artifacts" + && output.webhook.lambda.s3_key == "webhook.zip" + && output.webhook.lambda.s3_object_version == "legacy-webhook-version" + && toset(output.webhook.lambda.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) + && toset(output.webhook.lambda.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) + && output.webhook.lambda.tags["LegacyLambda"] == "legacy" + && !contains(keys(output.webhook.lambda.tags), "ExperimentalLambda") + && output.webhook.lambda_role.path == "/legacy/" + && output.webhook.lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + && toset(jsondecode(output.webhook.lambda.environment[0].variables["REPOSITORY_ALLOW_LIST"])) == toset(var.repository_white_list) + && output.webhook.lambda.environment[0].variables["QUEUE_SELECTION_STRATEGY"] == var.queue_selection_strategy + && output.webhook.eventbridge == null + && output.webhook.dispatcher == null + && output.runners_map["linux"].lambda_up.environment[0].variables["GHES_URL"] == "https://legacy.example.com" + && output.runners_map["linux"].lambda_up.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && output.runners_map["linux"].lambda_up.environment[0].variables["USER_AGENT"] == "legacy-user-agent" + ) + error_message = "The stable v1 runner and shared webhook must use flat Lambda, artifact, network, tag, role, and GitHub inputs while ignoring experimental globals." + } + + assert { + condition = ( + keys(output.binaries_syncer_map) == ["linux_x64"] + && output.binaries_syncer_map["linux_x64"].lambda.runtime == "nodejs20.x" + && output.binaries_syncer_map["linux_x64"].lambda.architectures == tolist(["x86_64"]) + && output.binaries_syncer_map["linux_x64"].lambda.memory_size == 640 + && output.binaries_syncer_map["linux_x64"].lambda.timeout == 70 + && toset(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) + && toset(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) + && output.binaries_syncer_map["linux_x64"].lambda.tags["LegacyLambda"] == "legacy" + && !contains(keys(output.binaries_syncer_map["linux_x64"].lambda.tags), "ExperimentalLambda") + && output.binaries_syncer_map["linux_x64"].lambda_role.path == "/legacy/" + && output.binaries_syncer_map["linux_x64"].lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + && output.binaries_syncer_map["linux_x64"].lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && output.binaries_syncer_map["linux_x64"].lambda.tracing_config[0].mode == "Active" + && output.binaries_syncer_map["linux_x64"].lambda_log_group.retention_in_days == 14 + && output.binaries_syncer_map["linux_x64"].lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.binaries_syncer_map["linux_x64"].lambda_log_group.log_group_class == "STANDARD" + ) + error_message = "The stable v1 binary syncer must receive the same flat Lambda, network, role, and observability values through the translated configuration." + } + + assert { + condition = ( + output.instance_termination_watcher.lambda.function.runtime == "nodejs20.x" + && output.instance_termination_watcher.lambda.function.architectures == tolist(["x86_64"]) + && output.instance_termination_watcher.lambda.function.memory_size == 448 + && output.instance_termination_watcher.lambda.function.timeout == 35 + && output.instance_termination_watcher.lambda.function.s3_bucket == "lambda-artifacts" + && output.instance_termination_watcher.lambda.function.s3_key == "termination-watcher.zip" + && output.instance_termination_watcher.lambda.function.s3_object_version == "legacy-watcher-version" + && toset(output.instance_termination_watcher.lambda.function.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) + && toset(output.instance_termination_watcher.lambda.function.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) + && output.instance_termination_watcher.lambda.function.tags["LegacyLambda"] == "legacy" + && !contains(keys(output.instance_termination_watcher.lambda.function.tags), "ExperimentalLambda") + && output.instance_termination_watcher.lambda_role.path == "/legacy/" + && output.instance_termination_watcher.lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://legacy.example.com" + && output.instance_termination_watcher.lambda.function.environment[0].variables["LOG_LEVEL"] == "warn" + && output.instance_termination_watcher.lambda.function.tracing_config[0].mode == "Active" + && output.instance_termination_watcher.lambda_log_group.retention_in_days == 14 + && output.instance_termination_watcher.lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.instance_termination_watcher.lambda_log_group.log_group_class == "STANDARD" + ) + error_message = "The stable v1 termination watcher must receive flat component settings and translated global Lambda, role, GitHub, and observability values." + } + + assert { + condition = ( + length(module.ami_housekeeper) == 1 + && module.ami_housekeeper[0].lambda.runtime == "nodejs20.x" + && module.ami_housekeeper[0].lambda.architectures == tolist(["x86_64"]) + && module.ami_housekeeper[0].lambda.memory_size == 384 + && module.ami_housekeeper[0].lambda.timeout == 90 + && module.ami_housekeeper[0].lambda.s3_bucket == "lambda-artifacts" + && module.ami_housekeeper[0].lambda.s3_key == "ami-housekeeper.zip" + && module.ami_housekeeper[0].lambda.s3_object_version == "legacy-ami-housekeeper-version" + && module.ami_housekeeper[0].lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).maxItems == 7 + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).minimumDaysOld == 14 + && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).dryRun + && module.ami_housekeeper[0].lambda_role.path == "/legacy/" + && module.ami_housekeeper[0].lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" + ) + error_message = "The stable v1 AMI housekeeper must preserve flat component settings through the translated compute-provider global while inheriting translated Lambda, role, and observability values." + } + + assert { + condition = ( + output.runners_map["linux"].lambda_up.environment[0].variables["LOG_LEVEL"] == "WARN" + && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LegacyMetrics" + && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && output.runners_map["linux"].lambda_up_log_group.retention_in_days == 14 + && output.runners_map["linux"].lambda_up_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.runners_map["linux"].lambda_up_log_group.log_group_class == "STANDARD" + && output.runners_map["linux"].lambda_up.tracing_config[0].mode == "Active" + && output.webhook.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && output.webhook.lambda.tracing_config[0].mode == "Active" + && output.webhook.lambda_log_group.retention_in_days == 14 + && output.webhook.lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" + && output.webhook.lambda_log_group.log_group_class == "STANDARD" + ) + error_message = "Experimental observability globals must not override stable v1 logging, tracing, or metrics inputs." + } + + assert { + condition = ( + local.translated_experimental.ssm.paths.root == "/legacy-root/github-actions" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" + && var.kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" + && output.ssm_parameters.id.name == "/legacy-root/github-actions/legacy-app/github_app_id" + && output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == "/legacy-root/github-actions/legacy-webhook/runner-matcher-config" + && output.runners_map["linux"].lambda_up.environment[0].variables["SSM_TOKEN_PATH"] == "/legacy-root/github-actions/linux/legacy-runners/tokens" + && output.runners_map["linux"].lambda_up.environment[0].variables["SSM_CONFIG_PATH"] == "/legacy-root/github-actions/linux/legacy-runners/config" + && tomap({ + for tag in jsondecode(output.runners_map["linux"].lambda_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + tag.Key => tag.Value + }) == tomap({ + StableGlobal = "global" + LegacyParameter = "legacy" + "ghr:environment" = "github-actions-linux" + Precedence = "legacy-parameter" + }) + ) + error_message = "Experimental SSM globals must not affect stable v1 shared paths, runner paths, KMS selection, or parameter tags." + } + + assert { + condition = keys(output.runners_map) == ["linux"] + 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( + [ + "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." + } +} diff --git a/modules/multi-runner/tests/provider-routing.tftest.hcl b/modules/multi-runner/tests/provider-routing-v2.tftest.hcl similarity index 68% rename from modules/multi-runner/tests/provider-routing.tftest.hcl rename to modules/multi-runner/tests/provider-routing-v2.tftest.hcl index 2869365299..64684be753 100644 --- a/modules/multi-runner/tests/provider-routing.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing-v2.tftest.hcl @@ -33,788 +33,6 @@ 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." - } - - assert { - condition = ( - length(local.raw_translated_experimental.multi_runner_config) == 0 - && length(local.translated_experimental.multi_runner_config) == 0 - && length(local.webhook_runner_config) == 0 - && length(local.runner_matcher_config) == 0 - && length(module.runner_configs) == 0 - ) - error_message = "An empty stable and experimental configuration must translate to an empty raw runner-configuration map without selecting a v2 runner configuration." - } - - assert { - condition = ( - local.github_app_parameters.webhook_secret != null - && module.ssm.parameters.github_app_webhook_secret != null - && output.ssm_parameters.webhook_secret != null - && output.webhook != null - ) - error_message = "Stable v1 must retain its shared webhook and webhook-secret parameter even when multi_runner_config is empty." - } -} - -run "stable_v1_keeps_legacy_runner_module" { - command = plan - - variables { - tags = { - StableGlobal = "global" - Precedence = "global" - } - - repository_white_list = ["legacy-owner/legacy-repository"] - queue_selection_strategy = "random" - eventbridge = { - enable = false - accept_events = ["workflow_job"] - } - matcher_config_parameter_store_tier = "Advanced" - webhook_lambda_apigateway_access_log_settings = { - destination_arn = "arn:aws:logs:eu-west-1:123456789012:log-group:legacy-api-access" - format = "$context.requestId" - } - webhook_lambda_s3_object_version = "legacy-webhook-version" - - lambda_runtime = "nodejs20.x" - lambda_architecture = "x86_64" - lambda_subnet_ids = ["subnet-legacy-lambda"] - lambda_security_group_ids = ["sg-legacy-lambda"] - lambda_principals = [{ - type = "AWS" - identifiers = ["arn:aws:iam::123456789012:role/legacy-lambda-principal"] - }] - webhook_lambda_memory_size = 320 - webhook_lambda_timeout = 25 - runners_scale_up_lambda_timeout = 47 - runners_lambda_s3_object_version = "legacy-runners-version" - runner_binaries_syncer_memory_size = 640 - runner_binaries_syncer_lambda_timeout = 70 - role_path = "/legacy/" - role_permissions_boundary = "arn:aws:iam::123456789012:policy/legacy-boundary" - ghes_url = "https://legacy.example.com" - ghes_ssl_verify = false - user_agent = "legacy-user-agent" - log_level = "warn" - logging_retention_in_days = 14 - logging_kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" - log_class = "STANDARD" - kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" - - queue_encryption = { - kms_data_key_reuse_period_seconds = 300 - kms_master_key_id = "arn:aws:kms:eu-west-1:123456789012:key/legacy-queue" - sqs_managed_sse_enabled = null - } - - lambda_tags = { - LegacyLambda = "legacy" - } - - tracing_config = { - mode = "Active" - capture_http_requests = true - capture_error = false - } - - metrics = { - enable = true - namespace = "LegacyMetrics" - metric = { - enable_github_app_rate_limit = true - enable_job_retry = false - enable_spot_termination_warning = false - } - } - - ssm_paths = { - root = "legacy-root" - app = "legacy-app" - runners = "legacy-runners" - webhook = "legacy-webhook" - } - - parameter_store_tags = { - LegacyParameter = "legacy" - Precedence = "legacy-parameter" - } - - runners_ssm_housekeeper = { - schedule_expression = "rate(12 hours)" - enabled = false - lambda_memory_size = 320 - lambda_timeout = 45 - config = { - tokenPath = "/legacy/cleanup/tokens" - minimumDaysOld = 5 - dryRun = true - } - } - - instance_termination_watcher = { - enable = true - enable_runner_deregistration = true - environment_variables = { - LEGACY_WATCHER = "true" - } - features = { - enable_spot_termination_handler = true - enable_spot_termination_notification_watcher = true - } - memory_size = 448 - timeout = 35 - s3_key = "termination-watcher.zip" - s3_object_version = "legacy-watcher-version" - } - - enable_ami_housekeeper = true - ami_housekeeper_lambda_memory_size = 384 - ami_housekeeper_lambda_timeout = 90 - ami_housekeeper_lambda_s3_key = "ami-housekeeper.zip" - ami_housekeeper_lambda_s3_object_version = "legacy-ami-housekeeper-version" - ami_housekeeper_lambda_schedule_expression = "rate(2 days)" - ami_housekeeper_cleanup_config = { - maxItems = 7 - minimumDaysOld = 14 - dryRun = true - } - - experimental = { - tags = { - ExperimentalOnly = "ignored" - } - roles = { - path = "/experimental/" - } - lambda = { - artifact = { - s3 = { - bucket = "experimental-ignored-artifacts" - } - } - runtime = "nodejs22.x" - architecture = "sparc64" - principals = [{ - type = "AWS" - identifiers = ["arn:aws:iam::123456789012:role/experimental-ignored-principal"] - }] - subnet_ids = ["subnet-experimental-lambda"] - security_group_ids = ["sg-experimental-lambda"] - tags = { - ExperimentalLambda = "ignored" - } - } - - orchestration = { - webhook = { - lambda = { - webhook = { - memory_size = 896 - timeout = 90 - } - } - } - } - github = { - app = { - id = "incomplete-experimental-id" - } - additional_apps = [{ id = "incomplete-additional-app-id" }] - enterprise_server = { - url = "https://experimental.example.com" - ssl_verify = true - } - user_agent = "experimental-user-agent" - } - ssm = { - paths = { - root = "relative-experimental-root" - tokens = "experimental-tokens" - config = "experimental-config" - } - kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-ssm" - tags = { - ExperimentalSsm = "ignored" - } - parameters = { - tags = { - ExperimentalParameter = "ignored" - } - } - housekeeper = { - schedule_expression = "rate(1 hour)" - state = "PAUSED" - lambda = { - memory_size = 896 - timeout = 90 - } - config = { - tokenPath = "/experimental/cleanup/tokens" - minimumDaysOld = 1 - dryRun = false - } - } - } - observability = { - logs = { - level = "verbose" - retention_in_days = 30 - kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-logs" - class = "ARCHIVE" - } - tracing = { - mode = "PassThrough" - capture_http_requests = false - capture_error = true - } - metrics = { - enable = false - namespace = "ExperimentalMetrics" - metric = { - enable_github_app_rate_limit = false - enable_job_retry = true - enable_spot_termination_warning = true - } - } - } - } - - multi_runner_config = { - linux = { - runner_config = { - runner_os = "linux" - runner_architecture = "x64" - instance_types = ["m5.large"] - runners_maximum_count = 2 - enable_runner_binaries_syncer = true - enable_organization_runners = true - delay_webhook_event = 17 - job_queue_retention_in_seconds = 12345 - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] - } - redrive_build_queue = { - enabled = true - maxReceiveCount = 3 - } - } - } - } - - assert { - condition = ( - toset(keys(local.raw_translated_experimental)) == toset([ - "tags", - "roles", - "runner", - "github", - "lambda", - "orchestration", - "ssm", - "observability", - "compute_provider", - "multi_runner_config", - ]) - && toset(keys(local.raw_translated_experimental.github)) == toset([ - "app", - "additional_apps", - "enterprise_server", - "user_agent", - ]) - && toset(keys(local.raw_translated_experimental.lambda)) == toset([ - "artifact", - "runtime", - "architecture", - "principals", - "subnet_ids", - "security_group_ids", - "tags", - "role", - ]) - && toset(keys(local.raw_translated_experimental.orchestration)) == toset([ - "webhook", - ]) - && toset(keys(local.raw_translated_experimental.orchestration.webhook)) == toset([ - "queue_selection_strategy", - "eventbridge", - "matcher_config_parameter_store_tier", - "runner", - "github", - "lambda", - "queue", - ]) - && toset(keys(local.raw_translated_experimental.orchestration.webhook.runner)) == toset([ - "boot_time_in_minutes", - "ephemeral", - "jit_config_enabled", - "maximum_count", - ]) - && toset(keys(local.raw_translated_experimental.orchestration.webhook.github)) == toset([ - "repository_white_list", - ]) - && toset(keys(local.raw_translated_experimental.orchestration.webhook.lambda)) == toset([ - "artifact", - "scale", - "webhook", - "pool", - ]) - && toset(keys(local.raw_translated_experimental.orchestration.webhook.lambda.scale)) == toset([ - "up", - "down", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"])) == toset([ - "tags", - "runner", - "lambda", - "orchestration", - "ssm", - "observability", - "compute_provider", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].lambda)) == toset([ - "runtime", - "architecture", - "subnet_ids", - "security_group_ids", - "tags", - "role", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration)) == toset([ - "webhook", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook)) == toset([ - "runner", - "github", - "lambda", - "queue", - "job_retry", - "matcherConfig", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda)) == toset([ - "scale", - "pool", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale)) == toset([ - "up", - "down", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue)) == toset([ - "delay_webhook_event", - "job_queue_retention_in_seconds", - "visibility_timeout_seconds", - "redrive_build_queue", - "tags", - ]) - && toset(keys(local.raw_translated_experimental.orchestration.webhook.queue)) == toset([ - "delay_webhook_event", - "job_queue_retention_in_seconds", - "visibility_timeout_seconds", - "redrive_build_queue", - "tags", - "encryption", - ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["ec2"]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].ssm), "kms_key_id") - && !contains(keys(local.raw_translated_experimental.runner), "boot_time_in_minutes") - && !contains(keys(local.raw_translated_experimental.runner), "ephemeral") - && !contains(keys(local.raw_translated_experimental.runner), "jit_config_enabled") - && !contains(keys(local.raw_translated_experimental.runner), "maximum_count") - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "ephemeral") - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "maximum_count") - && local.raw_translated_experimental.orchestration.webhook.runner.boot_time_in_minutes == 5 - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"]), "scale_up") - ) - error_message = "Stable v1 must translate into the exact raw experimental schema before defaults, queue event-source mappings, binary artifacts, or runner-config component shapes are resolved." - } - - assert { - condition = ( - toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) - && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null - && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3)) == toset(["arn", "id", "key"]) - ) - error_message = "Stable translation must discover binary-syncer runner configurations from the base object, then enrich the final canonical configuration with its resolved S3 distribution." - } - - assert { - condition = ( - local.raw_translated_experimental.tags == var.tags - && local.raw_translated_experimental.roles.path == var.role_path - && local.raw_translated_experimental.roles.permissions_boundary == var.role_permissions_boundary - && local.raw_translated_experimental.github.app == var.github_app - && local.raw_translated_experimental.github.additional_apps == var.additional_github_apps - && local.raw_translated_experimental.orchestration.webhook.github.repository_white_list == var.repository_white_list - && local.raw_translated_experimental.github.enterprise_server.url == var.ghes_url - && local.raw_translated_experimental.github.enterprise_server.ssl_verify == var.ghes_ssl_verify - && local.raw_translated_experimental.github.user_agent == var.user_agent - && local.raw_translated_experimental.orchestration.webhook.queue_selection_strategy == var.queue_selection_strategy - && local.raw_translated_experimental.orchestration.webhook.eventbridge == var.eventbridge - && local.raw_translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == var.matcher_config_parameter_store_tier - && local.raw_translated_experimental.orchestration.webhook.lambda.artifact.zip == null - && local.raw_translated_experimental.lambda.artifact.s3.bucket == var.lambda_s3_bucket - && local.raw_translated_experimental.orchestration.webhook.lambda.artifact.s3.key == var.runners_lambda_s3_key - && local.raw_translated_experimental.orchestration.webhook.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version - && local.raw_translated_experimental.lambda.runtime == var.lambda_runtime - && local.raw_translated_experimental.lambda.architecture == var.lambda_architecture - && local.raw_translated_experimental.lambda.principals == var.lambda_principals - && local.raw_translated_experimental.lambda.subnet_ids == var.lambda_subnet_ids - && local.raw_translated_experimental.lambda.security_group_ids == var.lambda_security_group_ids - && local.raw_translated_experimental.lambda.tags == var.lambda_tags - && local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == var.lambda_event_source_mapping_batch_size - && local.raw_translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == var.lambda_event_source_mapping_maximum_batching_window_in_seconds - && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip == null - && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.key == var.webhook_lambda_s3_key - && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.object_version == var.webhook_lambda_s3_object_version - && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings == var.webhook_lambda_apigateway_access_log_settings - && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.memory_size == var.webhook_lambda_memory_size - && local.raw_translated_experimental.orchestration.webhook.lambda.webhook.timeout == var.webhook_lambda_timeout - && local.raw_translated_experimental.orchestration.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout - && local.raw_translated_experimental.orchestration.webhook.queue.encryption == var.queue_encryption - && local.raw_translated_experimental.ssm.paths.root == "/legacy-root/github-actions" - && local.raw_translated_experimental.ssm.paths.app == var.ssm_paths.app - && local.raw_translated_experimental.ssm.paths.webhook == var.ssm_paths.webhook - && local.raw_translated_experimental.ssm.paths.tokens == "${var.ssm_paths.runners}/tokens" - && local.raw_translated_experimental.ssm.paths.config == "${var.ssm_paths.runners}/config" - && local.raw_translated_experimental.ssm.kms_key_id == var.kms_key_arn - && local.raw_translated_experimental.ssm.parameters.tags == var.parameter_store_tags - && local.raw_translated_experimental.observability.logs.level == var.log_level - && local.raw_translated_experimental.observability.logs.retention_in_days == var.logging_retention_in_days - && local.raw_translated_experimental.observability.logs.kms_key_id == var.logging_kms_key_id - && local.raw_translated_experimental.observability.logs.class == var.log_class - && local.raw_translated_experimental.observability.tracing == var.tracing_config - && local.raw_translated_experimental.observability.metrics.enable == var.metrics.enable - && local.raw_translated_experimental.observability.metrics.namespace == var.metrics.namespace - && local.raw_translated_experimental.observability.metrics.metric.enable_github_app_rate_limit == var.metrics.metric.enable_github_app_rate_limit - && local.raw_translated_experimental.observability.metrics.metric.enable_job_retry == var.metrics.metric.enable_job_retry - && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination - && local.raw_translated_experimental.observability.metrics.metric.enable_spot_termination_warning == var.metrics.metric.enable_spot_termination_warning - && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.zip == null - && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3.key == var.runners_lambda_s3_key - && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version - && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.key == var.runners_lambda_s3_key - && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.vpc_id == var.vpc_id - && local.raw_translated_experimental.compute_provider.ec2.subnet_ids == var.subnet_ids - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.enabled == var.enable_ami_housekeeper - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.cleanup_config == var.ami_housekeeper_cleanup_config - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.zip == null - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key == var.ami_housekeeper_lambda_s3_key - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.object_version == var.ami_housekeeper_lambda_s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.memory_size == var.ami_housekeeper_lambda_memory_size - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.timeout == var.ami_housekeeper_lambda_timeout - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.schedule.expression == var.ami_housekeeper_lambda_schedule_expression - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled == var.instance_termination_watcher.enable - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.features == var.instance_termination_watcher.features - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration == var.instance_termination_watcher.enable_runner_deregistration - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables == var.instance_termination_watcher.environment_variables - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip == null - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key == var.instance_termination_watcher.s3_key - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version == var.instance_termination_watcher.s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.memory_size == var.instance_termination_watcher.memory_size - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.timeout == var.instance_termination_watcher.timeout - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.enabled - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm == var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.tags == var.runner_binaries_s3_tags - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == var.runner_binaries_s3_versioning - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key == var.syncer_lambda_s3_key - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version == var.syncer_lambda_s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size == var.runner_binaries_syncer_memory_size - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.timeout == var.runner_binaries_syncer_lambda_timeout - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == var.state_event_rule_binaries_syncer - && local.raw_translated_experimental.multi_runner_config["linux"].runner.os == var.multi_runner_config["linux"].runner_config.runner_os - && local.raw_translated_experimental.multi_runner_config["linux"].runner.architecture == var.multi_runner_config["linux"].runner_config.runner_architecture - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "ephemeral") - && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.boot_time_in_minutes == var.multi_runner_config["linux"].runner_config.runner_boot_time_in_minutes - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.ephemeral == var.multi_runner_config["linux"].runner_config.enable_ephemeral_runners - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.jit_config_enabled == var.multi_runner_config["linux"].runner_config.enable_jit_config - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.maximum_count == var.multi_runner_config["linux"].runner_config.runners_maximum_count - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.github.organization_runners == var.multi_runner_config["linux"].runner_config.enable_organization_runners - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == var.multi_runner_config["linux"].runner_config.lambda_event_source_mapping_batch_size - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.delay_webhook_event == var.multi_runner_config["linux"].runner_config.delay_webhook_event - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.job_queue_retention_in_seconds == var.multi_runner_config["linux"].runner_config.job_queue_retention_in_seconds - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.redrive_build_queue == var.multi_runner_config["linux"].redrive_build_queue - && local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled - && local.raw_translated_experimental.multi_runner_config["linux"].orchestration.webhook.matcherConfig == var.multi_runner_config["linux"].matcherConfig - ) - error_message = "Stable v1 flat and per-runner inputs must populate every raw translation family while conflicting experimental globals remain inactive." - } - - assert { - condition = ( - !var.runners_ssm_housekeeper.enabled - && local.translated_experimental.ssm.housekeeper.state == "DISABLED" - && keys(module.runners) == ["linux"] - ) - error_message = "Stable v1 must translate runners_ssm_housekeeper.enabled=false to the DISABLED child event-rule state while retaining module.runners ownership." - } - - assert { - condition = keys(local.runner_config_by_provider.ec2) == ["linux"] - error_message = "Stable multi_runner_config entries must route to the EC2 provider." - } - - assert { - condition = ( - !local.use_multi_runner_config_v2 - && toset(keys(local.raw_translated_experimental.multi_runner_config)) == toset(["linux"]) - && toset(keys(local.translated_experimental.multi_runner_config)) == toset(["linux"]) - && length(module.runner_configs) == 0 - && keys(module.runners) == ["linux"] - ) - error_message = "Stable multi_runner_config entries must keep the original runner configuration and remain isolated from v2." - } - - assert { - condition = ( - var.experimental.github.app.key_base64 == null - && var.experimental.github.app.webhook_secret == null - && var.experimental.github.additional_apps[0].key_base64 == null - && var.experimental.lambda.architecture == "sparc64" - && var.experimental.observability.logs.level == "verbose" - && var.experimental.observability.logs.class == "ARCHIVE" - && var.experimental.ssm.paths.root == "relative-experimental-root" - && var.experimental.ssm.housekeeper.state == "PAUSED" - && !local.use_multi_runner_config_v2 - && keys(module.runners) == ["linux"] - ) - error_message = "Invalid but unused experimental sibling globals must remain gated when a stable v1 configuration owns the deployment." - } - - assert { - condition = ( - contains(keys(local.translated_experimental.multi_runner_config["linux"]), "compute_provider") - && !contains(keys(local.translated_experimental.multi_runner_config["linux"]), "runner_config") - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.github.organization_runners - ) - error_message = "Stable module inputs must use the canonical translated runner configuration while retaining stable module.runners ownership." - } - - assert { - condition = keys(module.runners) == ["linux"] && length(module.runner_configs) == 0 - 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 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 { - condition = ( - aws_sqs_queue.queued_builds["linux"].delay_seconds == 17 - && aws_sqs_queue.queued_builds["linux"].message_retention_seconds == 12345 - && aws_sqs_queue.queued_builds["linux"].visibility_timeout_seconds == var.runners_scale_up_lambda_timeout - && aws_sqs_queue.queued_builds["linux"].kms_master_key_id == var.queue_encryption.kms_master_key_id - && aws_sqs_queue.queued_builds["linux"].kms_data_key_reuse_period_seconds == var.queue_encryption.kms_data_key_reuse_period_seconds - && aws_sqs_queue.queued_builds_dlq["linux"].kms_master_key_id == var.queue_encryption.kms_master_key_id - && aws_sqs_queue.queued_builds_dlq["linux"].kms_data_key_reuse_period_seconds == var.queue_encryption.kms_data_key_reuse_period_seconds - ) - error_message = "Stable v1 queues must retain per-runner delay and retention plus flat timeout and encryption inputs after translation." - } - - assert { - condition = ( - output.runners_map["linux"].lambda_up.runtime == "nodejs20.x" - && output.runners_map["linux"].lambda_up.s3_bucket == var.lambda_s3_bucket - && output.runners_map["linux"].lambda_up.s3_key == var.runners_lambda_s3_key - && output.runners_map["linux"].lambda_up.s3_object_version == "legacy-runners-version" - && output.runners_map["linux"].role_scale_up.path == "/legacy/" - && output.webhook.lambda.runtime == "nodejs20.x" - && output.webhook.lambda.architectures == tolist(["x86_64"]) - && output.webhook.lambda.memory_size == 320 - && output.webhook.lambda.timeout == 25 - && output.webhook.lambda.s3_bucket == "lambda-artifacts" - && output.webhook.lambda.s3_key == "webhook.zip" - && output.webhook.lambda.s3_object_version == "legacy-webhook-version" - && toset(output.webhook.lambda.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) - && toset(output.webhook.lambda.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) - && output.webhook.lambda.tags["LegacyLambda"] == "legacy" - && !contains(keys(output.webhook.lambda.tags), "ExperimentalLambda") - && output.webhook.lambda_role.path == "/legacy/" - && output.webhook.lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" - && toset(jsondecode(output.webhook.lambda.environment[0].variables["REPOSITORY_ALLOW_LIST"])) == toset(var.repository_white_list) - && output.webhook.lambda.environment[0].variables["QUEUE_SELECTION_STRATEGY"] == var.queue_selection_strategy - && output.webhook.eventbridge == null - && output.webhook.dispatcher == null - && output.runners_map["linux"].lambda_up.environment[0].variables["GHES_URL"] == "https://legacy.example.com" - && output.runners_map["linux"].lambda_up.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" - && output.runners_map["linux"].lambda_up.environment[0].variables["USER_AGENT"] == "legacy-user-agent" - ) - error_message = "The stable v1 runner and shared webhook must use flat Lambda, artifact, network, tag, role, and GitHub inputs while ignoring experimental globals." - } - - assert { - condition = ( - keys(output.binaries_syncer_map) == ["linux_x64"] - && output.binaries_syncer_map["linux_x64"].lambda.runtime == "nodejs20.x" - && output.binaries_syncer_map["linux_x64"].lambda.architectures == tolist(["x86_64"]) - && output.binaries_syncer_map["linux_x64"].lambda.memory_size == 640 - && output.binaries_syncer_map["linux_x64"].lambda.timeout == 70 - && toset(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) - && toset(output.binaries_syncer_map["linux_x64"].lambda.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) - && output.binaries_syncer_map["linux_x64"].lambda.tags["LegacyLambda"] == "legacy" - && !contains(keys(output.binaries_syncer_map["linux_x64"].lambda.tags), "ExperimentalLambda") - && output.binaries_syncer_map["linux_x64"].lambda_role.path == "/legacy/" - && output.binaries_syncer_map["linux_x64"].lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" - && output.binaries_syncer_map["linux_x64"].lambda.environment[0].variables["LOG_LEVEL"] == "WARN" - && output.binaries_syncer_map["linux_x64"].lambda.tracing_config[0].mode == "Active" - && output.binaries_syncer_map["linux_x64"].lambda_log_group.retention_in_days == 14 - && output.binaries_syncer_map["linux_x64"].lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" - && output.binaries_syncer_map["linux_x64"].lambda_log_group.log_group_class == "STANDARD" - ) - error_message = "The stable v1 binary syncer must receive the same flat Lambda, network, role, and observability values through the translated configuration." - } - - assert { - condition = ( - output.instance_termination_watcher.lambda.function.runtime == "nodejs20.x" - && output.instance_termination_watcher.lambda.function.architectures == tolist(["x86_64"]) - && output.instance_termination_watcher.lambda.function.memory_size == 448 - && output.instance_termination_watcher.lambda.function.timeout == 35 - && output.instance_termination_watcher.lambda.function.s3_bucket == "lambda-artifacts" - && output.instance_termination_watcher.lambda.function.s3_key == "termination-watcher.zip" - && output.instance_termination_watcher.lambda.function.s3_object_version == "legacy-watcher-version" - && toset(output.instance_termination_watcher.lambda.function.vpc_config[0].subnet_ids) == toset(["subnet-legacy-lambda"]) - && toset(output.instance_termination_watcher.lambda.function.vpc_config[0].security_group_ids) == toset(["sg-legacy-lambda"]) - && output.instance_termination_watcher.lambda.function.tags["LegacyLambda"] == "legacy" - && !contains(keys(output.instance_termination_watcher.lambda.function.tags), "ExperimentalLambda") - && output.instance_termination_watcher.lambda_role.path == "/legacy/" - && output.instance_termination_watcher.lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" - && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://legacy.example.com" - && output.instance_termination_watcher.lambda.function.environment[0].variables["LOG_LEVEL"] == "warn" - && output.instance_termination_watcher.lambda.function.tracing_config[0].mode == "Active" - && output.instance_termination_watcher.lambda_log_group.retention_in_days == 14 - && output.instance_termination_watcher.lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" - && output.instance_termination_watcher.lambda_log_group.log_group_class == "STANDARD" - ) - error_message = "The stable v1 termination watcher must receive flat component settings and translated global Lambda, role, GitHub, and observability values." - } - - assert { - condition = ( - length(module.ami_housekeeper) == 1 - && module.ami_housekeeper[0].lambda.runtime == "nodejs20.x" - && module.ami_housekeeper[0].lambda.architectures == tolist(["x86_64"]) - && module.ami_housekeeper[0].lambda.memory_size == 384 - && module.ami_housekeeper[0].lambda.timeout == 90 - && module.ami_housekeeper[0].lambda.s3_bucket == "lambda-artifacts" - && module.ami_housekeeper[0].lambda.s3_key == "ami-housekeeper.zip" - && module.ami_housekeeper[0].lambda.s3_object_version == "legacy-ami-housekeeper-version" - && module.ami_housekeeper[0].lambda.environment[0].variables["LOG_LEVEL"] == "WARN" - && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).maxItems == 7 - && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).minimumDaysOld == 14 - && jsondecode(module.ami_housekeeper[0].lambda.environment[0].variables["AMI_CLEANUP_OPTIONS"]).dryRun - && module.ami_housekeeper[0].lambda_role.path == "/legacy/" - && module.ami_housekeeper[0].lambda_role.permissions_boundary == "arn:aws:iam::123456789012:policy/legacy-boundary" - ) - error_message = "The stable v1 AMI housekeeper must preserve flat component settings through the translated compute-provider global while inheriting translated Lambda, role, and observability values." - } - - assert { - condition = ( - output.runners_map["linux"].lambda_up.environment[0].variables["LOG_LEVEL"] == "WARN" - && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LegacyMetrics" - && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" - && output.runners_map["linux"].lambda_up.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" - && output.runners_map["linux"].lambda_up_log_group.retention_in_days == 14 - && output.runners_map["linux"].lambda_up_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" - && output.runners_map["linux"].lambda_up_log_group.log_group_class == "STANDARD" - && output.runners_map["linux"].lambda_up.tracing_config[0].mode == "Active" - && output.webhook.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" - && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" - && output.webhook.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" - && output.webhook.lambda.tracing_config[0].mode == "Active" - && output.webhook.lambda_log_group.retention_in_days == 14 - && output.webhook.lambda_log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-logs" - && output.webhook.lambda_log_group.log_group_class == "STANDARD" - ) - error_message = "Experimental observability globals must not override stable v1 logging, tracing, or metrics inputs." - } - - assert { - condition = ( - local.translated_experimental.ssm.paths.root == "/legacy-root/github-actions" - && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" - && var.kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/legacy-ssm" - && output.ssm_parameters.id.name == "/legacy-root/github-actions/legacy-app/github_app_id" - && output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == "/legacy-root/github-actions/legacy-webhook/runner-matcher-config" - && output.runners_map["linux"].lambda_up.environment[0].variables["SSM_TOKEN_PATH"] == "/legacy-root/github-actions/linux/legacy-runners/tokens" - && output.runners_map["linux"].lambda_up.environment[0].variables["SSM_CONFIG_PATH"] == "/legacy-root/github-actions/linux/legacy-runners/config" - && tomap({ - for tag in jsondecode(output.runners_map["linux"].lambda_up.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : - tag.Key => tag.Value - }) == tomap({ - StableGlobal = "global" - LegacyParameter = "legacy" - "ghr:environment" = "github-actions-linux" - Precedence = "legacy-parameter" - }) - ) - error_message = "Experimental SSM globals must not affect stable v1 shared paths, runner paths, KMS selection, or parameter tags." - } - - assert { - condition = keys(output.runners_map) == ["linux"] - 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( - [ - "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_provider_stack" { command = plan @@ -1006,7 +224,7 @@ run "experimental_v2_routes_through_provider_stack" { }] } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -1061,7 +279,7 @@ run "experimental_v2_routes_through_provider_stack" { } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -1159,7 +377,7 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = toset(flatten(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.matcherConfig.labelMatchers)) == toset(["self-hosted", "linux", "x64"]) + condition = toset(flatten(local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.matcherConfig.labelMatchers)) == toset(["self-hosted", "linux", "x64"]) error_message = "The canonical experimental runner configuration must retain labels declared by its matcher configuration for the runner adapter." } @@ -1196,22 +414,22 @@ run "experimental_v2_routes_through_provider_stack" { && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "ephemeral") && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "jit_config_enabled") && !contains(keys(local.translated_experimental.multi_runner_config["linux"].runner), "maximum_count") - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.boot_time_in_minutes == 5 - && !local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.ephemeral - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.jit_config_enabled == null - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.runner.maximum_count == 2 - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" - && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" - && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" - && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.boot_time_in_minutes == 5 + && !local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.ephemeral + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.jit_config_enabled == null + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.runner.maximum_count == 2 + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" + && module.runner_configs["linux"].orchestration_provider.webhook.pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "2" + && module.runner_configs["linux"].orchestration_provider.webhook.pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "5" ) error_message = "Experimental v2 common runner defaults must stay provider-neutral while webhook-owned lifecycle, capacity, and boot time reach their controls without stable-input fallback." } assert { condition = ( - local.translated_experimental.orchestration.webhook.lambda.artifact.zip == "README.md" - && local.translated_experimental.orchestration.webhook.lambda.artifact.s3 == null + local.translated_experimental.orchestration_provider.webhook.lambda.artifact.zip == "README.md" + && local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3 == null && local.translated_experimental.lambda.artifact.s3.bucket == null && local.translated_experimental.multi_runner_config["linux"].lambda.artifact.s3.bucket == null && toset(keys(local.translated_experimental.multi_runner_config["linux"].lambda)) == toset([ @@ -1226,18 +444,18 @@ run "experimental_v2_routes_through_provider_stack" { ]) && !contains(keys(local.translated_experimental.multi_runner_config["linux"].lambda), "zip") && !contains(keys(local.translated_experimental.multi_runner_config["linux"].lambda), "s3") - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.artifact.zip == "README.md" - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.artifact.s3 == null - && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda)) == toset([ + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.artifact.zip == "README.md" + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.artifact.s3 == null + && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda)) == toset([ "artifact", "scale", "pool", ]) - && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale)) == toset([ + && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale)) == toset([ "up", "down", ]) - && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue)) == toset([ + && toset(keys(local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue)) == toset([ "delay_webhook_event", "job_queue_retention_in_seconds", "visibility_timeout_seconds", @@ -1253,53 +471,53 @@ run "experimental_v2_routes_through_provider_stack" { && length(local.translated_experimental.multi_runner_config["linux"].lambda.tags) == 0 && local.translated_experimental.multi_runner_config["linux"].lambda.role.path == null && local.translated_experimental.multi_runner_config["linux"].lambda.role.permissions_boundary == null - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.runtime == "nodejs24.x" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.filename == "README.md" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.s3_bucket == null - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.memory_size == 512 - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.timeout == 30 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.reserved_concurrent_executions == 1 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.job_queued_check_enabled == null - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 10 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 - && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.memory_size == 512 - && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.timeout == 60 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.down.schedule_expression == "cron(*/5 * * * ? *)" - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes == null - && module.runner_configs["linux"].orchestration.webhook.pool.lambda.memory_size == 512 - && module.runner_configs["linux"].orchestration.webhook.pool.lambda.timeout == 60 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.pool.reserved_concurrent_executions == 1 - && !local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.pool.include_busy_runners - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.lambda.pool.runner_owner == null + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.runtime == "nodejs24.x" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.filename == "README.md" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.s3_bucket == null + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.memory_size == 512 + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.timeout == 30 + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions == 1 + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled == null + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == 10 + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && module.runner_configs["linux"].orchestration_provider.webhook.scale_down.lambda.memory_size == 512 + && module.runner_configs["linux"].orchestration_provider.webhook.scale_down.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale.down.schedule_expression == "cron(*/5 * * * ? *)" + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes == null + && module.runner_configs["linux"].orchestration_provider.webhook.pool.lambda.memory_size == 512 + && module.runner_configs["linux"].orchestration_provider.webhook.pool.lambda.timeout == 60 + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions == 1 + && !local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.pool.include_busy_runners + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.lambda.pool.runner_owner == null ) error_message = "Experimental v2 runner-config Lambda components must inherit the concrete nested defaults and ignore every corresponding stable Lambda input." } assert { condition = ( - local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.delay_webhook_event == 30 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.job_queue_retention_in_seconds == 86400 - && local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.visibility_timeout_seconds == 180 - && !local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.redrive_build_queue.enabled - && length(local.translated_experimental.multi_runner_config["linux"].orchestration.webhook.queue.tags) == 0 - && local.translated_experimental.orchestration.webhook.queue.encryption == var.experimental.orchestration.webhook.queue.encryption - && local.translated_experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled - && local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id == null - && local.translated_experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds == null + local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.delay_webhook_event == 30 + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.job_queue_retention_in_seconds == 86400 + && local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.visibility_timeout_seconds == 180 + && !local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.redrive_build_queue.enabled + && length(local.translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.tags) == 0 + && local.translated_experimental.orchestration_provider.webhook.queue.encryption == var.experimental.orchestration_provider.webhook.queue.encryption + && local.translated_experimental.orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled + && local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id == null + && local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds == null && aws_sqs_queue.queued_builds["linux"].delay_seconds == 30 && aws_sqs_queue.queued_builds["linux"].message_retention_seconds == 86400 && aws_sqs_queue.queued_builds["linux"].visibility_timeout_seconds == 180 && aws_sqs_queue.queued_builds["linux"].sqs_managed_sse_enabled && aws_sqs_queue.queued_builds["linux"].kms_master_key_id == null ) - error_message = "Experimental v2 queues must use orchestration.webhook.queue defaults, including a six-times-Lambda visibility timeout and SQS-managed encryption, instead of stable flat inputs." + error_message = "Experimental v2 queues must use orchestration_provider.webhook.queue defaults, including a six-times-Lambda visibility timeout and SQS-managed encryption, instead of stable flat inputs." } assert { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps - && length(local.translated_experimental.orchestration.webhook.github.repository_white_list) == 0 + && length(local.translated_experimental.orchestration_provider.webhook.github.repository_white_list) == 0 && !contains(keys(local.translated_experimental), "enterprise_server") && !contains(keys(local.translated_experimental), "user_agent") && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server @@ -1307,26 +525,26 @@ run "experimental_v2_routes_through_provider_stack" { && local.translated_experimental.github.enterprise_server.url == null && local.translated_experimental.github.enterprise_server.ssl_verify && local.translated_experimental.github.user_agent == "github-aws-runners" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == null - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "1" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" - && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" - && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == null + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "1" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_down.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" + && module.runner_configs["linux"].orchestration_provider.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "github-aws-runners" ) error_message = "V2 runner configurations must use concrete nested GitHub connection defaults and the webhook-owned repository allow-list rather than deliberately different flat inputs." } assert { condition = ( - local.translated_experimental.orchestration.webhook.queue_selection_strategy == "first" - && local.translated_experimental.orchestration.webhook.eventbridge.enable - && length(local.translated_experimental.orchestration.webhook.eventbridge.accept_events) == 0 - && local.translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == "Standard" - && local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip == "README.md" - && local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3 == null - && local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings == null - && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 10 - && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 + local.translated_experimental.orchestration_provider.webhook.queue_selection_strategy == "first" + && local.translated_experimental.orchestration_provider.webhook.eventbridge.enable + && length(local.translated_experimental.orchestration_provider.webhook.eventbridge.accept_events) == 0 + && local.translated_experimental.orchestration_provider.webhook.matcher_config_parameter_store_tier == "Standard" + && local.translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.zip == "README.md" + && local.translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3 == null + && local.translated_experimental.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings == null + && local.translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == 10 + && local.translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 ) error_message = "V2 webhook controls, the explicitly nested local artifact, API access-log defaults, and scale-up event-source mappings must avoid flat-input fallback." } @@ -1373,17 +591,17 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "INFO" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GitHub Runners" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/github-action-runners/github-actions/linux/runners/tokens" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/github-action-runners/github-actions/linux/runners/config" - && module.runner_configs["linux"].orchestration.webhook.scale_up.log_group.retention_in_days == 180 - && module.runner_configs["linux"].orchestration.webhook.scale_up.log_group.kms_key_id == null - && module.runner_configs["linux"].orchestration.webhook.scale_up.log_group.log_group_class == "STANDARD" - && length(module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.tracing_config) == 0 - && jsondecode(module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) == [{ + module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "INFO" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GitHub Runners" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/github-action-runners/github-actions/linux/runners/tokens" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/github-action-runners/github-actions/linux/runners/config" + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.log_group.retention_in_days == 180 + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.log_group.kms_key_id == null + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.log_group.log_group_class == "STANDARD" + && length(module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.tracing_config) == 0 + && jsondecode(module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) == [{ Key = "ghr:environment" Value = "github-actions" }] @@ -1511,9 +729,9 @@ run "experimental_v2_routes_through_provider_stack" { && length(local.github_app_parameters.id) == 2 && length(module.ssm.additional_app_parameters) == 1 && module.ssm.additional_app_parameters[0].id.name == "/github-runner/additional-app-id" - && module.runner_configs["linux"].orchestration.webhook.scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == join(":", [for p in local.github_app_parameters.id : p.name]) - && module.runner_configs["linux"].orchestration.webhook.scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) - && module.runner_configs["linux"].orchestration.webhook.pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) + && module.runner_configs["linux"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == join(":", [for p in local.github_app_parameters.id : p.name]) + && module.runner_configs["linux"].orchestration_provider.webhook.scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) + && module.runner_configs["linux"].orchestration_provider.webhook.pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) ) error_message = "Experimental v2 control-plane Lambdas must preserve the complete multi-app parameter lists from the shared multi-runner configuration." } @@ -1538,26 +756,26 @@ run "experimental_v2_routes_through_provider_stack" { [ "provider", "runner", - "orchestration", + "orchestration_provider", "scale_up", "scale_down", "pool", ] ) - error_message = "Experimental v2 runners_map_v2 entries must add canonical orchestration while retaining the existing component aliases." + error_message = "Experimental v2 runners_map_v2 entries must add the canonical orchestration_provider grouping while retaining the existing component aliases." } assert { condition = ( toset(keys(output.runners_map_v2["linux"].runner)) == toset(["role"]) - && toset(keys(output.runners_map_v2["linux"].orchestration.webhook.scale_up)) == toset(["lambda", "log_group", "role"]) - && toset(keys(output.runners_map_v2["linux"].orchestration.webhook.scale_down)) == toset(["lambda", "log_group", "role"]) - && toset(keys(output.runners_map_v2["linux"].orchestration.webhook.pool)) == toset(["lambda", "log_group", "role"]) - && output.runners_map_v2["linux"].scale_up == output.runners_map_v2["linux"].orchestration.webhook.scale_up - && output.runners_map_v2["linux"].scale_down == output.runners_map_v2["linux"].orchestration.webhook.scale_down - && output.runners_map_v2["linux"].pool == output.runners_map_v2["linux"].orchestration.webhook.pool + && toset(keys(output.runners_map_v2["linux"].orchestration_provider.webhook.scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map_v2["linux"].orchestration_provider.webhook.scale_down)) == toset(["lambda", "log_group", "role"]) + && toset(keys(output.runners_map_v2["linux"].orchestration_provider.webhook.pool)) == toset(["lambda", "log_group", "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 and orchestration contracts while preserving compatibility output aliases." + error_message = "Experimental v2 common resources must use the nested runner and orchestration_provider contracts while preserving compatibility output aliases." } assert { @@ -1584,7 +802,7 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = local.runner_config_by_provider.ec2["linux"].orchestration.webhook.lambda.scale.down.idle_config[0].idleCount == 1 + condition = local.runner_config_by_provider.ec2["linux"].orchestration_provider.webhook.lambda.scale.down.idle_config[0].idleCount == 1 error_message = "Webhook-owned idle configuration must remain in the orchestration provider input contract." } @@ -1602,7 +820,7 @@ run "experimental_v2_routes_through_provider_stack" { } } -run "experimental_v2_rejects_missing_orchestration_block" { +run "experimental_v2_rejects_missing_orchestration_provider" { command = plan plan_options { @@ -1619,8 +837,8 @@ run "experimental_v2_rejects_missing_orchestration_block" { } compute_provider = { ec2 = { - vpc_id = "vpc-missing-orchestration" - subnet_ids = ["subnet-missing-orchestration"] + vpc_id = "vpc-missing-orchestration-provider" + subnet_ids = ["subnet-missing-orchestration-provider"] } } multi_runner_config = { @@ -1629,7 +847,7 @@ run "experimental_v2_rejects_missing_orchestration_block" { os = "linux" architecture = "x64" } - orchestration = {} + orchestration_provider = {} compute_provider = { ec2 = { instance_types = ["m5.large"] @@ -1671,7 +889,7 @@ run "experimental_v2_requires_webhook_maximum_count" { os = "linux" architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64"]] @@ -1773,7 +991,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { }] } - orchestration = { + orchestration_provider = { webhook = { runner = { boot_time_in_minutes = 6 @@ -1975,7 +1193,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { } } - orchestration = { + orchestration_provider = { webhook = { runner = { boot_time_in_minutes = 7 @@ -2039,10 +1257,10 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].runner), "boot_time_in_minutes") && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].runner), "ephemeral") && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].runner), "jit_config_enabled") - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.boot_time_in_minutes == 7 - && !local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.ephemeral - && !local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.jit_config_enabled - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.runner.maximum_count == 4 + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.runner.boot_time_in_minutes == 7 + && !local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.runner.ephemeral + && !local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.runner.jit_config_enabled + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.runner.maximum_count == 4 && local.translated_experimental.multi_runner_config["resolved"].runner.group_name == "lane-group" && local.translated_experimental.ssm.housekeeper.lambda.artifact.s3.key == "global-ssm-housekeeper.zip" && local.translated_experimental.multi_runner_config["resolved"].ssm.housekeeper.lambda.artifact.zip == "README.md" @@ -2060,7 +1278,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { && toset(keys(local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer.s3 != null && keys(output.binaries_syncer_map) == ["linux_x64"] - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" ) error_message = "Global compute-provider defaults must merge into the required runner-configuration selector while configuration values take precedence." } @@ -2105,49 +1323,49 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { assert { condition = ( - module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.runtime == "nodejs22.x" - && local.translated_experimental.orchestration.webhook.lambda.artifact.zip == null - && local.translated_experimental.orchestration.webhook.lambda.artifact.s3.key == "nested-runners.zip" - && local.translated_experimental.orchestration.webhook.lambda.artifact.s3.object_version == "nested-runners-version" + module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.runtime == "nodejs22.x" + && local.translated_experimental.orchestration_provider.webhook.lambda.artifact.zip == null + && local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.key == "nested-runners.zip" + && local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.object_version == "nested-runners-version" && local.translated_experimental.lambda.artifact.s3.bucket == "experimental-lambda-artifacts" && local.translated_experimental.multi_runner_config["resolved"].lambda.artifact.s3.bucket == "experimental-lambda-artifacts" && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].lambda), "zip") && !contains(keys(local.translated_experimental.multi_runner_config["resolved"].lambda), "s3") - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.artifact.zip == null - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.artifact.s3.key == "nested-runners.zip" - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.artifact.s3.object_version == "nested-runners-version" + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.lambda.artifact.zip == null + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.lambda.artifact.s3.key == "nested-runners.zip" + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.lambda.artifact.s3.object_version == "nested-runners-version" && local.translated_experimental.lambda.principals == tolist([{ type = "Service" identifiers = tolist(["states.amazonaws.com"]) }]) - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.s3_bucket == "experimental-lambda-artifacts" - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.s3_key == "nested-runners.zip" - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.s3_object_version == "nested-runners-version" - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.memory_size == 896 - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.timeout == 40 - && module.runner_configs["resolved"].orchestration.webhook.scale_down.lambda.timeout == 75 - && module.runner_configs["resolved"].orchestration.webhook.pool.lambda.memory_size == 448 - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 50 + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.s3_bucket == "experimental-lambda-artifacts" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.s3_key == "nested-runners.zip" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.s3_object_version == "nested-runners-version" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.memory_size == 896 + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.timeout == 40 + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_down.lambda.timeout == 75 + && module.runner_configs["resolved"].orchestration_provider.webhook.pool.lambda.memory_size == 448 + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == 50 && module.runner_configs["resolved"].runner.role.path == "/experimental/" - && module.runner_configs["resolved"].orchestration.webhook.scale_up.role.path == "/lane-lambda/" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.role.path == "/lane-lambda/" ) error_message = "V2 values must resolve in configuration-over-experimental-global precedence order without stable-input fallback." } assert { condition = ( - local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.delay_webhook_event == 11 - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.job_queue_retention_in_seconds == 172800 - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.visibility_timeout_seconds == 300 - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.redrive_build_queue.enabled - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount == 7 - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.queue.tags == tomap({ + local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.queue.delay_webhook_event == 11 + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.queue.job_queue_retention_in_seconds == 172800 + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.queue.visibility_timeout_seconds == 300 + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.queue.redrive_build_queue.enabled + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount == 7 + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.queue.tags == tomap({ GlobalQueue = "global" LaneQueue = "lane" Precedence = "lane" }) - && local.translated_experimental.orchestration.webhook.queue.encryption == var.experimental.orchestration.webhook.queue.encryption - && local.translated_experimental.ssm.kms_key_id == local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id + && local.translated_experimental.orchestration_provider.webhook.queue.encryption == var.experimental.orchestration_provider.webhook.queue.encryption + && local.translated_experimental.ssm.kms_key_id == local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id && aws_sqs_queue.queued_builds["resolved"].delay_seconds == 11 && aws_sqs_queue.queued_builds["resolved"].message_retention_seconds == 172800 && aws_sqs_queue.queued_builds["resolved"].visibility_timeout_seconds == 300 @@ -2172,7 +1390,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps - && local.translated_experimental.orchestration.webhook.github.repository_white_list == var.experimental.orchestration.webhook.github.repository_white_list + && local.translated_experimental.orchestration_provider.webhook.github.repository_white_list == var.experimental.orchestration_provider.webhook.github.repository_white_list && !contains(keys(local.translated_experimental), "enterprise_server") && !contains(keys(local.translated_experimental), "user_agent") && local.translated_experimental.github.enterprise_server == var.experimental.github.enterprise_server @@ -2181,13 +1399,13 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { && !local.translated_experimental.github.enterprise_server.ssl_verify && local.translated_experimental.github.user_agent == "experimental-runner-user-agent" && length(local.translated_experimental.github.additional_apps) == 0 - && local.translated_experimental.multi_runner_config["resolved"].orchestration.webhook.job_retry.enabled - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" - && module.runner_configs["resolved"].orchestration.webhook.scale_down.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" - && module.runner_configs["resolved"].orchestration.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" - && module.runner_configs["resolved"].orchestration.webhook.scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == module.ssm.parameters.github_app_id.name + && local.translated_experimental.multi_runner_config["resolved"].orchestration_provider.webhook.job_retry.enabled + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_down.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" + && module.runner_configs["resolved"].orchestration_provider.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" + && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == module.ssm.parameters.github_app_id.name && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables["NESTED_WATCHER"] == "true" && output.instance_termination_watcher.lambda.function.runtime == "nodejs22.x" && output.instance_termination_watcher.lambda.function.architectures == tolist(["arm64"]) @@ -2231,7 +1449,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { assert { condition = ( local.translated_experimental.lambda.runtime == "nodejs22.x" - && var.experimental.orchestration.webhook.lambda.webhook.memory_size == 384 + && var.experimental.orchestration_provider.webhook.lambda.webhook.memory_size == 384 && output.webhook.lambda.runtime == "nodejs22.x" && output.webhook.lambda.architectures == tolist(["arm64"]) && output.webhook.lambda.memory_size == 384 @@ -2244,10 +1462,10 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { && output.webhook.lambda.environment[0].variables["QUEUE_SELECTION_STRATEGY"] == "all" && output.webhook.eventbridge == null && output.webhook.dispatcher == null - && local.translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier == "Advanced" - && local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" - && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 25 - && local.translated_experimental.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 + && local.translated_experimental.orchestration_provider.webhook.matcher_config_parameter_store_tier == "Advanced" + && local.translated_experimental.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == "arn:aws:logs:eu-west-1:123456789012:log-group:nested-api-access" + && local.translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == 25 + && local.translated_experimental.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 0 && !contains(keys(output.webhook.lambda.environment[0].variables), "GHES_URL") ) error_message = "The shared webhook must consume its provider-owned repository allow-list plus nested GitHub connection, routing, eventbridge, matcher-tier, artifact, API-access-log, Lambda, and role globals without flat-input leakage." @@ -2326,7 +1544,7 @@ run "experimental_v2_layers_observability_and_ssm" { user_agent = "experimental-observability-user-agent" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -2438,7 +1656,7 @@ run "experimental_v2_layers_observability_and_ssm" { Precedence = "inherited" } - orchestration = { + orchestration_provider = { webhook = { matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64", "inherited"]] @@ -2462,7 +1680,7 @@ run "experimental_v2_layers_observability_and_ssm" { Precedence = "overridden" } - orchestration = { + orchestration_provider = { webhook = { matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64", "overridden"]] @@ -2631,39 +1849,39 @@ run "experimental_v2_layers_observability_and_ssm" { assert { condition = ( - module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-observability.example.com" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-observability-user-agent" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GlobalMetrics" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "true" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.tracing_config[0].mode == "Active" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.retention_in_days == 30 - && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-logs" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.log_group_class == "INFREQUENT_ACCESS" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LaneMetrics" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "false" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "true" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.tracing_config[0].mode == "PassThrough" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.retention_in_days == 7 - && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/lane-logs" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.log_group_class == "STANDARD" + module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-observability.example.com" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["USER_AGENT"] == "experimental-observability-user-agent" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "GlobalMetrics" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "true" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "false" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.tracing_config[0].mode == "Active" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.log_group.retention_in_days == 30 + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/global-logs" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.log_group.log_group_class == "INFREQUENT_ACCESS" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "WARN" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_METRICS_NAMESPACE"] == "LaneMetrics" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["ENABLE_METRIC_GITHUB_APP_RATE_LIMIT"] == "false" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "false" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "true" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.tracing_config[0].mode == "PassThrough" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.log_group.retention_in_days == 7 + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/lane-logs" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.log_group.log_group_class == "STANDARD" ) error_message = "Resolved global GitHub connection settings and global/per-configuration observability must reach runner-config Lambda and log-group resources." } assert { condition = ( - module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/global-ssm/inherited/global-tokens" - && module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/global-ssm/inherited/global-config" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/lane-ssm/overridden/lane-tokens" - && module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/lane-ssm/overridden/lane-config" + module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/global-ssm/inherited/global-tokens" + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/global-ssm/inherited/global-config" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_TOKEN_PATH"] == "/lane-ssm/overridden/lane-tokens" + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_CONFIG_PATH"] == "/lane-ssm/overridden/lane-config" && tomap({ - for tag in jsondecode(module.runner_configs["inherited"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + for tag in jsondecode(module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ ExperimentalOnly = "experimental" @@ -2674,7 +1892,7 @@ run "experimental_v2_layers_observability_and_ssm" { "ghr:environment" = "github-actions" }) && tomap({ - for tag in jsondecode(module.runner_configs["overridden"].orchestration.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + for tag in jsondecode(module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ ExperimentalOnly = "experimental" @@ -2701,14 +1919,14 @@ run "experimental_v2_layers_observability_and_ssm" { LaneHousekeeperOnly = "lane-housekeeper" Precedence = "lane-housekeeper" }) - && module.runner_configs["inherited"].orchestration.webhook.scale_up.log_group.tags == tomap({ + && module.runner_configs["inherited"].orchestration_provider.webhook.scale_up.log_group.tags == tomap({ ExperimentalOnly = "experimental" InheritedOnly = "inherited" GlobalLogOnly = "global-log" Precedence = "global-log" "ghr:environment" = "github-actions" }) - && module.runner_configs["overridden"].orchestration.webhook.scale_up.log_group.tags == tomap({ + && module.runner_configs["overridden"].orchestration_provider.webhook.scale_up.log_group.tags == tomap({ ExperimentalOnly = "experimental" OverriddenOnly = "overridden" GlobalLogOnly = "global-log" @@ -2781,7 +1999,7 @@ run "experimental_v2_requires_global_github_app" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -2849,7 +2067,7 @@ run "experimental_v2_rejects_incomplete_global_github_app" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -2921,7 +2139,7 @@ run "experimental_v2_rejects_incomplete_additional_github_app" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -2961,7 +2179,7 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -2989,7 +2207,7 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3058,7 +2276,7 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -3086,7 +2304,7 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3153,7 +2371,7 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled url = "https://experimental-disabled-deregistration.example.com" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -3188,7 +2406,7 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3229,7 +2447,7 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && !local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration && output.instance_termination_watcher != null - && module.runner_configs["disabled_deregistration"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-disabled-deregistration.example.com" + && module.runner_configs["disabled_deregistration"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-disabled-deregistration.example.com" ) error_message = "The termination watcher must remain enabled while the v2 runner configuration uses the translated enterprise-server URL when deregistration is disabled." } @@ -3256,7 +2474,7 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { url = "https://experimental-watcher.example.com" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -3291,7 +2509,7 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3332,7 +2550,7 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" - && module.runner_configs["mismatched_watcher_ghes"].orchestration.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" + && module.runner_configs["mismatched_watcher_ghes"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" && var.ghes_url == "https://flat-watcher.example.com" ) error_message = "An enabled v2 termination watcher must use the translated enterprise-server URL instead of a deliberately different flat GHES URL." @@ -3353,7 +2571,7 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3384,7 +2602,7 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { } multi_runner_config = { flat_only = { - orchestration = { + orchestration_provider = { webhook = { matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64", "flat-only-kms"]] @@ -3439,7 +2657,7 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3473,7 +2691,7 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { } multi_runner_config = { experimental_only = { - orchestration = { + orchestration_provider = { webhook = { matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64", "experimental-only-kms"]] @@ -3528,7 +2746,7 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3562,7 +2780,7 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { } multi_runner_config = { mismatched = { - orchestration = { + orchestration_provider = { webhook = { matcherConfig = { labelMatchers = [["self-hosted", "linux", "x64", "mismatched-kms"]] @@ -3614,7 +2832,7 @@ run "experimental_v2_external_role_ignores_global_iam_management" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -3660,7 +2878,7 @@ run "experimental_v2_external_role_ignores_global_iam_management" { } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3759,7 +2977,7 @@ run "experimental_v2_rejects_explicit_iam_management_with_external_role" { } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3822,7 +3040,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -3868,7 +3086,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -3962,7 +3180,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_configs["tagged"].orchestration.webhook.scale_up.lambda.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration_provider.webhook.scale_up.lambda.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" ExperimentalLambdaOnly = "experimental-lambda" @@ -3975,7 +3193,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_configs["tagged"].orchestration.webhook.scale_up.log_group.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration_provider.webhook.scale_up.log_group.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" SharedLogOnly = "shared-log" @@ -3987,7 +3205,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_configs["tagged"].orchestration.webhook.scale_up.role.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration_provider.webhook.scale_up.role.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" ScaleUpOnly = "scale-up" @@ -4009,7 +3227,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_configs["tagged"].orchestration.webhook.scale_down.lambda.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration_provider.webhook.scale_down.lambda.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" ExperimentalLambdaOnly = "experimental-lambda" @@ -4022,7 +3240,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = module.runner_configs["tagged"].orchestration.webhook.scale_down.log_group.tags == tomap({ + condition = module.runner_configs["tagged"].orchestration_provider.webhook.scale_down.log_group.tags == tomap({ ExperimentalOnly = "experimental" RunnerConfigOnly = "runner-config" SharedLogOnly = "shared-log" @@ -4034,7 +3252,7 @@ run "experimental_v2_layers_shared_and_component_tags" { } assert { - condition = output.runners_map_v2["tagged"].orchestration.webhook.pool == null + condition = output.runners_map_v2["tagged"].orchestration_provider.webhook.pool == null error_message = "Experimental v2 must expose a null pool object when no pool configuration is supplied." } } @@ -4055,7 +3273,7 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { scale = { @@ -4080,7 +3298,7 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4138,7 +3356,7 @@ run "experimental_v2_rejects_conflicting_queue_encryption" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { queue = { encryption = { @@ -4163,7 +3381,7 @@ run "experimental_v2_rejects_conflicting_queue_encryption" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4204,7 +3422,7 @@ run "experimental_v2_rejects_queue_kms_alias" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { queue = { encryption = { @@ -4229,7 +3447,7 @@ run "experimental_v2_rejects_queue_kms_alias" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4273,7 +3491,7 @@ run "experimental_v2_rejects_queue_kms_key_id" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { queue = { encryption = { @@ -4298,7 +3516,7 @@ run "experimental_v2_rejects_queue_kms_key_id" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4342,7 +3560,7 @@ run "experimental_v2_rejects_redrive_without_max_receive_count" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { queue = { redrive_build_queue = { @@ -4364,7 +3582,7 @@ run "experimental_v2_rejects_redrive_without_max_receive_count" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4421,7 +3639,7 @@ run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4480,7 +3698,7 @@ run "experimental_v2_rejects_runner_artifact_zip_and_s3" { } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -4505,7 +3723,7 @@ run "experimental_v2_rejects_runner_artifact_zip_and_s3" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4554,7 +3772,7 @@ run "experimental_v2_rejects_runner_artifact_bucket_without_key" { } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -4578,7 +3796,7 @@ run "experimental_v2_rejects_runner_artifact_bucket_without_key" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4619,7 +3837,7 @@ run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -4643,7 +3861,7 @@ run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4715,7 +3933,7 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_zip_and_s3" { os = "linux" architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4778,7 +3996,7 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_bucket" { os = "linux" architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4848,7 +4066,7 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_key" { os = "linux" architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4918,7 +4136,7 @@ run "experimental_v2_rejects_runner_binaries_artifact_zip_and_s3" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -4982,7 +4200,7 @@ run "experimental_v2_rejects_runner_binaries_logging_prefix_without_bucket" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -5024,7 +4242,7 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { webhook_secret = "test-secret" } } - orchestration = { + orchestration_provider = { webhook = { lambda = { artifact = { @@ -5071,7 +4289,7 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -5098,9 +4316,9 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { assert { condition = ( - local.translated_experimental.orchestration.webhook.queue.encryption.kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" + local.translated_experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/shared-control-plane" - && local.translated_experimental.multi_runner_config["mismatched_queue_kms"].orchestration.webhook.queue.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" + && local.translated_experimental.multi_runner_config["mismatched_queue_kms"].orchestration_provider.webhook.queue.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" && aws_sqs_queue.queued_builds["mismatched_queue_kms"].kms_master_key_id == "arn:aws:kms:eu-west-1:123456789012:key/queue-only" ) error_message = "V2 queue and shared SSM resources must accept and independently use distinct customer-managed KMS keys." @@ -5137,7 +4355,7 @@ run "experimental_v2_rejects_empty_compute_provider" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 @@ -5188,7 +4406,7 @@ run "experimental_v2_rejects_invalid_ssm_housekeeper_state" { architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 2 diff --git a/modules/multi-runner/validations.experimental.tf b/modules/multi-runner/validations.experimental.tf index d3c98b89c4..3885c3648f 100644 --- a/modules/multi-runner/validations.experimental.tf +++ b/modules/multi-runner/validations.experimental.tf @@ -69,7 +69,7 @@ resource "terraform_data" "validate_experimental" { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : length([ - for orchestration_type, orchestration_config in runner_config.orchestration : orchestration_type + for orchestration_type, orchestration_config in runner_config.orchestration_provider : orchestration_type if orchestration_config != null ]) == 1 ]) @@ -88,25 +88,25 @@ resource "terraform_data" "validate_experimental" { precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : - runner_config.orchestration.webhook == null ? true : + runner_config.orchestration_provider.webhook == null ? true : try(coalesce( - runner_config.orchestration.webhook.runner.boot_time_in_minutes, - var.experimental.orchestration.webhook.runner.boot_time_in_minutes, + runner_config.orchestration_provider.webhook.runner.boot_time_in_minutes, + var.experimental.orchestration_provider.webhook.runner.boot_time_in_minutes, ), null) != null ]) - error_message = "Each experimental webhook runner configuration must resolve orchestration.webhook.runner.boot_time_in_minutes from the configuration or experimental global webhook defaults." + error_message = "Each experimental webhook runner configuration must resolve orchestration_provider.webhook.runner.boot_time_in_minutes from the configuration or experimental global webhook defaults." } precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : - runner_config.orchestration.webhook == null ? true : + runner_config.orchestration_provider.webhook == null ? true : try(coalesce( - runner_config.orchestration.webhook.runner.maximum_count, - var.experimental.orchestration.webhook.runner.maximum_count, + runner_config.orchestration_provider.webhook.runner.maximum_count, + var.experimental.orchestration_provider.webhook.runner.maximum_count, ), null) != null ]) - error_message = "Each experimental webhook runner configuration must resolve orchestration.webhook.runner.maximum_count from the configuration or experimental global webhook defaults." + error_message = "Each experimental webhook runner configuration must resolve orchestration_provider.webhook.runner.maximum_count from the configuration or experimental global webhook defaults." } precondition { @@ -123,42 +123,42 @@ resource "terraform_data" "validate_experimental" { precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : - runner_config.orchestration.webhook == null ? true : ( + runner_config.orchestration_provider.webhook == null ? true : ( coalesce( - runner_config.orchestration.webhook.queue.visibility_timeout_seconds, - var.experimental.orchestration.webhook.queue.visibility_timeout_seconds, + runner_config.orchestration_provider.webhook.queue.visibility_timeout_seconds, + var.experimental.orchestration_provider.webhook.queue.visibility_timeout_seconds, ) >= 6 * coalesce( - runner_config.orchestration.webhook.lambda.scale.up.timeout, - var.experimental.orchestration.webhook.lambda.scale.up.timeout, + runner_config.orchestration_provider.webhook.lambda.scale.up.timeout, + var.experimental.orchestration_provider.webhook.lambda.scale.up.timeout, ) ) ]) - error_message = "Each experimental orchestration.webhook.queue.visibility_timeout_seconds must be at least six times the resolved orchestration.webhook.lambda.scale.up.timeout." + error_message = "Each experimental orchestration_provider.webhook.queue.visibility_timeout_seconds must be at least six times the resolved orchestration_provider.webhook.lambda.scale.up.timeout." } precondition { condition = !local.use_multi_runner_config_v2 || ( ( - var.experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled != null && - var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id == null && - var.experimental.orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds == null + var.experimental.orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled != null && + var.experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id == null && + var.experimental.orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds == null ) || ( - var.experimental.orchestration.webhook.queue.encryption.sqs_managed_sse_enabled == null && - var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id != null + var.experimental.orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled == null && + var.experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id != null ) ) - error_message = "Invalid experimental.orchestration.webhook.queue.encryption configuration for webhook orchestration. Use SQS-managed encryption, disable it, or configure a KMS key." + error_message = "Invalid experimental.orchestration_provider.webhook.queue.encryption configuration for webhook orchestration. Use SQS-managed encryption, disable it, or configure a KMS key." } precondition { condition = !local.use_multi_runner_config_v2 || ( - var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id == null || + var.experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id == null || can(regex( "^arn:[^:]+:kms:[^:]+:[0-9]{12}:key/.+$", - var.experimental.orchestration.webhook.queue.encryption.kms_master_key_id, + var.experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id, )) ) - error_message = "experimental.orchestration.webhook.queue.encryption.kms_master_key_id must be a KMS key ARN; key IDs and aliases cannot be used in runner-config IAM policies." + error_message = "experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id must be a KMS key ARN; key IDs and aliases cannot be used in runner-config IAM policies." } precondition { @@ -262,47 +262,47 @@ resource "terraform_data" "validate_experimental" { precondition { condition = !local.use_multi_runner_config_v2 || contains( ["first", "random", "all"], - var.experimental.orchestration.webhook.queue_selection_strategy, + var.experimental.orchestration_provider.webhook.queue_selection_strategy, ) - error_message = "experimental.orchestration.webhook.queue_selection_strategy must be first, random, or all." + error_message = "experimental.orchestration_provider.webhook.queue_selection_strategy must be first, random, or all." } precondition { condition = !local.use_multi_runner_config_v2 || contains( ["Standard", "Advanced"], - var.experimental.orchestration.webhook.matcher_config_parameter_store_tier, + var.experimental.orchestration_provider.webhook.matcher_config_parameter_store_tier, ) - error_message = "experimental.orchestration.webhook.matcher_config_parameter_store_tier must be Standard or Advanced." + error_message = "experimental.orchestration_provider.webhook.matcher_config_parameter_store_tier must be Standard or Advanced." } precondition { condition = !local.use_multi_runner_config_v2 || ( !( - var.experimental.orchestration.webhook.lambda.artifact.zip != null && - var.experimental.orchestration.webhook.lambda.artifact.s3 != null + var.experimental.orchestration_provider.webhook.lambda.artifact.zip != null && + var.experimental.orchestration_provider.webhook.lambda.artifact.s3 != null ) && ( - var.experimental.orchestration.webhook.lambda.artifact.s3 == null || ( + var.experimental.orchestration_provider.webhook.lambda.artifact.s3 == null || ( var.experimental.lambda.artifact.s3.bucket != null && - try(var.experimental.orchestration.webhook.lambda.artifact.s3.key != null, false) + try(var.experimental.orchestration_provider.webhook.lambda.artifact.s3.key != null, false) ) ) ) - error_message = "experimental.orchestration.webhook.lambda.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.orchestration_provider.webhook.lambda.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { condition = !local.use_multi_runner_config_v2 || ( !( - var.experimental.orchestration.webhook.lambda.webhook.artifact.zip != null && - var.experimental.orchestration.webhook.lambda.webhook.artifact.s3 != null + var.experimental.orchestration_provider.webhook.lambda.webhook.artifact.zip != null && + var.experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3 != null ) && ( - var.experimental.orchestration.webhook.lambda.webhook.artifact.s3 == null || ( + var.experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3 == null || ( var.experimental.lambda.artifact.s3.bucket != null && - try(var.experimental.orchestration.webhook.lambda.webhook.artifact.s3.key != null, false) + try(var.experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3.key != null, false) ) ) ) - error_message = "experimental.orchestration.webhook.lambda.webhook.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.orchestration_provider.webhook.lambda.webhook.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { @@ -405,14 +405,14 @@ resource "terraform_data" "validate_experimental" { precondition { condition = alltrue([ for runner_config in values(local.translated_experimental_base.multi_runner_config) : - runner_config.orchestration.webhook == null ? true : ( - !runner_config.orchestration.webhook.queue.redrive_build_queue.enabled || try( - runner_config.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount > 0, + runner_config.orchestration_provider.webhook == null ? true : ( + !runner_config.orchestration_provider.webhook.queue.redrive_build_queue.enabled || try( + runner_config.orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount > 0, false, ) ) ]) - error_message = "An enabled experimental orchestration.webhook.queue.redrive_build_queue requires maxReceiveCount greater than zero." + error_message = "An enabled experimental orchestration_provider.webhook.queue.redrive_build_queue requires maxReceiveCount greater than zero." } precondition { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index 79445278b9..e5a775e98d 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -71,69 +71,69 @@ variable "experimental" { - `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. - `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`. - `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`. - - `orchestration.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration`, where exactly one typed provider block must be non-null. - - `orchestration.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job. - - `orchestration.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. - - `orchestration.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events. - - `orchestration.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks. - - `orchestration.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering. - - `orchestration.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`. - - `orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`. - - `orchestration.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode. - - `orchestration.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally. - - `orchestration.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used. - - `orchestration.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null. - - `orchestration.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key. - - `orchestration.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive. - - `orchestration.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null. - - `orchestration.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`. - - `orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`. - - `orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. - - `orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. - - `orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. - - `orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`. - - `orchestration.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`. - - `orchestration.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`. - - `orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. - - `orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`. - - `orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. - - `orchestration.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`. - - `orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - - `orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. - - `orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period. - - `orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. - - `orchestration.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`. - - `orchestration.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. - - `orchestration.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null. - - `orchestration.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key. - - `orchestration.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive. - - `orchestration.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null. - - `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block. - - `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs. - - `orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format. - - `orchestration.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`. - - `orchestration.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`. - - `orchestration.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`. - - `orchestration.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`. - - `orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`. - - `orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency. - - `orchestration.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component. - - `orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. - - `orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. - - `orchestration.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule. - - `orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`. - - `orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null. - - `orchestration.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`. - - `orchestration.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`. - - `orchestration.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`. - - `orchestration.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration.webhook.lambda.scale.up.timeout` that inherits it. - - `orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`. - - `orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled. - - `orchestration.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`. - - `orchestration.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode. - - `orchestration.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`. - - `orchestration.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require. - - `orchestration.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`. + - `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null. + - `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job. + - `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. + - `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events. + - `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks. + - `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering. + - `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`. + - `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`. + - `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode. + - `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally. + - `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used. + - `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null. + - `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key. + - `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive. + - `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null. + - `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`. + - `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`. + - `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. + - `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. + - `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. + - `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`. + - `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`. + - `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`. + - `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. + - `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`. + - `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. + - `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`. + - `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. + - `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period. + - `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. + - `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`. + - `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null. + - `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key. + - `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive. + - `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null. + - `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block. + - `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs. + - `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format. + - `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`. + - `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`. + - `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`. + - `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`. + - `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`. + - `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency. + - `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component. + - `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule. + - `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`. + - `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null. + - `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`. + - `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`. + - `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`. + - `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it. + - `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`. + - `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled. + - `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`. + - `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode. + - `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`. + - `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require. + - `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`. - `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths. - `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`. - `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`. @@ -250,7 +250,7 @@ variable "experimental" { - `multi_runner_config[].runner.os`: Runner operating system. - `multi_runner_config[].runner.architecture`: Runner distribution architecture. - `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered. - - `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true. + - `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true. - `multi_runner_config[].runner.group_name`: GitHub runner group used during registration. - `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names. - `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider. @@ -265,7 +265,7 @@ variable "experimental" { - `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`. - `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role. - `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. - - `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration.webhook.lambda`. + - `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`. - `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions. - `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions. - `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions. @@ -273,65 +273,65 @@ variable "experimental" { - `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map. - `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`. - `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`. - - `multi_runner_config[].orchestration`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract. - - `multi_runner_config[].orchestration.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources. - - `multi_runner_config[].orchestration.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration.webhook.runner.boot_time_in_minutes`. - - `multi_runner_config[].orchestration.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration.webhook.runner.ephemeral`. - - `multi_runner_config[].orchestration.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode. - - `multi_runner_config[].orchestration.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration.webhook.runner.maximum_count`. - - `multi_runner_config[].orchestration.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used. - - `multi_runner_config[].orchestration.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration. - - `multi_runner_config[].orchestration.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels. - - `multi_runner_config[].orchestration.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels. - - `multi_runner_config[].orchestration.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job. - - `multi_runner_config[].orchestration.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels. - - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. - - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`. - - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`. - - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`. - - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`. - - `multi_runner_config[].orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. - - `multi_runner_config[].orchestration.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default. - - `multi_runner_config[].orchestration.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default. - - `multi_runner_config[].orchestration.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations. - - `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value. - - `multi_runner_config[].orchestration.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled. - - `multi_runner_config[].orchestration.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map. - - `multi_runner_config[].orchestration.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. - - `multi_runner_config[].orchestration.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. - - `multi_runner_config[].orchestration.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. - - `multi_runner_config[].orchestration.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode. - - `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. - - `multi_runner_config[].orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. - - `multi_runner_config[].orchestration.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. - - `multi_runner_config[].orchestration.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. - - `multi_runner_config[].orchestration.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. - - `multi_runner_config[].orchestration.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. - - `multi_runner_config[].orchestration.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. - - `multi_runner_config[].orchestration.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component. - - `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. - - `multi_runner_config[].orchestration.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. - - `multi_runner_config[].orchestration.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule. - - `multi_runner_config[].orchestration.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. - - `multi_runner_config[].orchestration.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners. - - `multi_runner_config[].orchestration.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. - - `multi_runner_config[].orchestration.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. - - `multi_runner_config[].orchestration.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. - - `multi_runner_config[].orchestration.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. - - `multi_runner_config[].orchestration.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. - - `multi_runner_config[].orchestration.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. - - `multi_runner_config[].orchestration.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. - - `multi_runner_config[].orchestration.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency. - - `multi_runner_config[].orchestration.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. + - `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract. + - `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources. + - `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`. + - `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`. + - `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode. + - `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`. + - `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`. + - `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null. + - `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default. + - `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default. + - `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations. + - `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value. + - `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled. + - `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. + - `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners. + - `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency. + - `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. - `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`. - `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration. - `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration. @@ -536,7 +536,7 @@ variable "experimental" { }), {}) }), {}) - orchestration = optional(object({ + orchestration_provider = optional(object({ webhook = optional(object({ queue_selection_strategy = optional(string, "first") eventbridge = optional(object({ @@ -868,7 +868,7 @@ variable "experimental" { }), {}) }), {}) - orchestration = object({ + orchestration_provider = object({ webhook = optional(object({ runner = optional(object({ boot_time_in_minutes = optional(number, null) diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index 89b74f6806..51bc3b6089 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -1,7 +1,7 @@ locals { webhook_runner_config = { for k, v in local.translated_experimental.multi_runner_config : k => v - if v.orchestration.webhook != null + if v.orchestration_provider.webhook != null } runner_matcher_config = { @@ -9,7 +9,7 @@ locals { id = aws_sqs_queue.queued_builds[k].id arn = aws_sqs_queue.queued_builds[k].arn computeProvider = local.compute_provider_types[k] - matcherConfig = v.orchestration.webhook.matcherConfig + matcherConfig = v.orchestration_provider.webhook.matcherConfig } } } @@ -19,9 +19,9 @@ module "webhook" { prefix = var.prefix tags = merge(local.translated_experimental.tags, { "ghr:environment" = var.prefix }) kms_key_arn = local.translated_experimental.ssm.kms_key_id - eventbridge = local.translated_experimental.orchestration.webhook.eventbridge + eventbridge = local.translated_experimental.orchestration_provider.webhook.eventbridge runner_matcher_config = local.runner_matcher_config - matcher_config_parameter_store_tier = local.translated_experimental.orchestration.webhook.matcher_config_parameter_store_tier + matcher_config_parameter_store_tier = local.translated_experimental.orchestration_provider.webhook.matcher_config_parameter_store_tier ssm_paths = { root = trimsuffix(coalesce(local.translated_experimental.ssm.paths.root, "/github-action-runners/${var.prefix}"), "/") @@ -32,16 +32,16 @@ module "webhook" { webhook_secret = local.github_app_parameters.webhook_secret } - lambda_s3_bucket = local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket - webhook_lambda_s3_key = try(local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.key, null) - webhook_lambda_s3_object_version = try(local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.s3.object_version, null) - webhook_lambda_apigateway_access_log_settings = local.translated_experimental.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings + lambda_s3_bucket = local.translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + webhook_lambda_s3_key = try(local.translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3.key, null) + webhook_lambda_s3_object_version = try(local.translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version, null) + webhook_lambda_apigateway_access_log_settings = local.translated_experimental.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings lambda_runtime = local.translated_experimental.lambda.runtime lambda_architecture = local.translated_experimental.lambda.architecture - lambda_zip = local.translated_experimental.orchestration.webhook.lambda.webhook.artifact.zip - lambda_timeout = local.translated_experimental.orchestration.webhook.lambda.webhook.timeout - lambda_memory_size = local.translated_experimental.orchestration.webhook.lambda.webhook.memory_size - lambda_tags = merge(local.translated_experimental.lambda.tags, local.translated_experimental.orchestration.webhook.lambda.webhook.tags) + lambda_zip = local.translated_experimental.orchestration_provider.webhook.lambda.webhook.artifact.zip + lambda_timeout = local.translated_experimental.orchestration_provider.webhook.lambda.webhook.timeout + lambda_memory_size = local.translated_experimental.orchestration_provider.webhook.lambda.webhook.memory_size + lambda_tags = merge(local.translated_experimental.lambda.tags, local.translated_experimental.orchestration_provider.webhook.lambda.webhook.tags) tracing_config = local.translated_experimental.observability.tracing logging_retention_in_days = local.translated_experimental.observability.logs.retention_in_days logging_kms_key_id = local.translated_experimental.observability.logs.kms_key_id @@ -49,8 +49,8 @@ module "webhook" { role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) - repository_white_list = local.translated_experimental.orchestration.webhook.github.repository_white_list - queue_selection_strategy = local.translated_experimental.orchestration.webhook.queue_selection_strategy + repository_white_list = local.translated_experimental.orchestration_provider.webhook.github.repository_white_list + queue_selection_strategy = local.translated_experimental.orchestration_provider.webhook.queue_selection_strategy lambda_subnet_ids = local.translated_experimental.lambda.subnet_ids lambda_security_group_ids = local.translated_experimental.lambda.security_group_ids diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index 253830030e..81c6def4d3 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -2,7 +2,7 @@ This internal module owns the event-driven runner demand controls used by `runner-config`: scale-up, scale-down, scheduled pool reconciliation, and optional queued-job retry. It receives the common GitHub, Lambda, runner-registration, SSM, observability, and selected compute-provider contracts from the parent configuration module, then resolves webhook-specific defaults and tag precedence before invoking its leaf modules. Lifecycle, boot time, and capacity are provider-owned under `config.runner`; the provider resolves the lifecycle contract for runner bootstrap, forwards capacity to scale-up and pool, and forwards boot time to scale-down and pool. It also combines the shared Lambda artifact bucket with its own `config.lambda.artifact` zip or S3 key/version shared by scale, pool, and job-retry; provider-specific artifact fields do not leak into the common Lambda contract. -`runner-config` selects this provider when `orchestration.webhook` is the one populated orchestration block. The parent continues to own common runner resources, shared SSM configuration, and compute-provider selection. A future orchestration provider should be implemented as a sibling module with the same parent-facing resource boundary; it should not add its stateful resources to this webhook module. +`runner-config` selects this provider when `orchestration_provider.webhook` is the one populated orchestration block. The parent continues to own common runner resources, shared SSM configuration, and compute-provider selection. A future orchestration provider should be implemented as a sibling module with the same parent-facing resource boundary; it should not add its stateful resources to this webhook module. The scale-down lifecycle is documented in the [scale-down state diagram](./scale-down-state-diagram.md). @@ -11,12 +11,14 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers -No providers. +| Name | Version | +|------|---------| +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -28,14 +30,16 @@ No providers. ## Resources -No resources. +| Name | Type | +|------|------| +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | -| [config](#input\_config) | Provider-owned webhook values supplied from `orchestration.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
- `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 applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | +| [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
- `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 applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | | [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection. |
object({
artifact = object({
s3 = object({
bucket = optional(string, null)
})
})
runtime = string
architecture = string
subnet_ids = list(string)
security_group_ids = list(string)
tags = optional(map(string), {})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
| n/a | yes | | [observability](#input\_observability) | Common logging, tracing, and metrics configuration consumed by webhook controls. |
object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
tags = optional(map(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
})
})
})
| n/a | yes | diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md index f73c1053d1..5a572d25eb 100644 --- a/modules/orchestration-providers/webhook/job-retry/README.md +++ b/modules/orchestration-providers/webhook/job-retry/README.md @@ -12,7 +12,7 @@ The module is an inner module used by the webhook orchestration provider when th | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -20,6 +20,7 @@ The module is an inner module used by the webhook orchestration provider when th | Name | Version | |------|---------| | [aws](#provider\_aws) | >= 6.21 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -40,6 +41,7 @@ No modules. | [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 | +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | 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 | diff --git a/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl b/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl index f99570354e..ad17b77558 100644 --- a/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl +++ b/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl @@ -330,3 +330,57 @@ run "does_not_enable_partial_vpc_configuration" { error_message = "Partial VPC inputs must stay disabled and a null KMS key must omit the KMS statement entirely." } } + +run "rejects_unsupported_lambda_architecture" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + config = merge(var.config, { + lambda = merge(var.config.lambda, { + architecture = "unsupported" + }) + }) + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_unsupported_log_level" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + config = merge(var.config, { + observability = merge(var.config.observability, { + logs = merge(var.config.observability.logs, { + level = "verbose" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_resource_prefix_longer_than_aws_limit" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + config = merge(var.config, { + prefix = "1234567890123456789012345678901234567890123456789012345" + }) + } + + expect_failures = [terraform_data.validate_config] +} diff --git a/modules/orchestration-providers/webhook/job-retry/validations.tf b/modules/orchestration-providers/webhook/job-retry/validations.tf new file mode 100644 index 0000000000..832f53ddd5 --- /dev/null +++ b/modules/orchestration-providers/webhook/job-retry/validations.tf @@ -0,0 +1,26 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + condition = contains(["arm64", "x86_64"], var.config.lambda.architecture) + error_message = "config.lambda.architecture must be arm64 or x86_64." + } + + precondition { + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ], var.config.observability.logs.level) + error_message = "config.observability.logs.level must be one of silly, trace, debug, info, warn, error, or fatal." + } + + precondition { + 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/orchestration-providers/webhook/job-retry/variables.tf b/modules/orchestration-providers/webhook/job-retry/variables.tf index d6645c3bb6..b4df0402b5 100644 --- a/modules/orchestration-providers/webhook/job-retry/variables.tf +++ b/modules/orchestration-providers/webhook/job-retry/variables.tf @@ -140,27 +140,4 @@ variable "config" { }) nullable = false - - validation { - condition = contains(["arm64", "x86_64"], var.config.lambda.architecture) - error_message = "config.lambda.architecture must be arm64 or x86_64." - } - - validation { - condition = contains([ - "silly", - "trace", - "debug", - "info", - "warn", - "error", - "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 = 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/orchestration-providers/webhook/job-retry/versions.tf b/modules/orchestration-providers/webhook/job-retry/versions.tf index 42a40b33fd..fcec7c620d 100644 --- a/modules/orchestration-providers/webhook/job-retry/versions.tf +++ b/modules/orchestration-providers/webhook/job-retry/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index 1cf4a1a545..877eec8039 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/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) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -18,6 +18,7 @@ The pool is an opt-in feature. To be able to use the count on a module level to | Name | Version | |------|---------| | [aws](#provider\_aws) | >= 6.21 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -39,6 +40,7 @@ No modules. | [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 | +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | 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 | diff --git a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl index d65ca41b82..c04f435024 100644 --- a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl +++ b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl @@ -246,3 +246,52 @@ run "omits_optional_kms_statement" { error_message = "A null Parameter Store key must omit the optional pool KMS statement." } } + +run "rejects_empty_compute_provider_type" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + runner_provider = merge(var.runner_provider, { + type = " " + }) + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_invalid_compute_provider_policy" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + runner_provider = merge(var.runner_provider, { + iam_policy_json = "not-json" + }) + } + + expect_failures = [terraform_data.validate_config] +} + +run "requires_enabled_compute_provider_managed_policy_arn" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + runner_provider = merge(var.runner_provider, { + managed_policy_enabled = true + managed_policy_arn = null + }) + } + + expect_failures = [terraform_data.validate_config] +} diff --git a/modules/orchestration-providers/webhook/pool/validations.tf b/modules/orchestration-providers/webhook/pool/validations.tf new file mode 100644 index 0000000000..f18d251c23 --- /dev/null +++ b/modules/orchestration-providers/webhook/pool/validations.tf @@ -0,0 +1,18 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + condition = trimspace(var.runner_provider.type) != "" + error_message = "The compute provider type must not be empty." + } + + precondition { + condition = can(jsondecode(var.runner_provider.iam_policy_json)) + error_message = "The compute provider IAM policy must be valid JSON." + } + + precondition { + 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." + } + } +} diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf index 6337b0ea5f..e1f516c8ad 100644 --- a/modules/orchestration-providers/webhook/pool/variables.tf +++ b/modules/orchestration-providers/webhook/pool/variables.tf @@ -136,21 +136,6 @@ variable "runner_provider" { 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" { diff --git a/modules/orchestration-providers/webhook/pool/versions.tf b/modules/orchestration-providers/webhook/pool/versions.tf index 42a40b33fd..fcec7c620d 100644 --- a/modules/orchestration-providers/webhook/pool/versions.tf +++ b/modules/orchestration-providers/webhook/pool/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/orchestration-providers/webhook/scale-down-state-diagram.md b/modules/orchestration-providers/webhook/scale-down-state-diagram.md index dec780cfbf..2e94d9ba79 100644 --- a/modules/orchestration-providers/webhook/scale-down-state-diagram.md +++ b/modules/orchestration-providers/webhook/scale-down-state-diagram.md @@ -146,5 +146,5 @@ stateDiagram-v2 - **Cron Schedule**: `cron(*/5 * * * ? *)` (every 5 minutes) - **Minimum Runtime**: Linux 5min, Windows 15min, OSX 20min -- **Boot Timeout**: Configurable via `orchestration.webhook.runner.boot_time_in_minutes`; stable-v1 inputs are translated from `runner_boot_time_in_minutes`. +- **Boot Timeout**: Configurable via `orchestration_provider.webhook.runner.boot_time_in_minutes`; stable-v1 inputs are translated from `runner_boot_time_in_minutes`. - **Idle Config**: Per-environment configuration for desired idle runners diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index bcb6065b25..ba9ca9be9d 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -11,7 +11,7 @@ The module is an implementation detail of the experimental runner configuration. | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers @@ -19,6 +19,7 @@ The module is an implementation detail of the experimental runner configuration. | Name | Version | |------|---------| | [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -50,6 +51,7 @@ No modules. | [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 | +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | 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 | diff --git a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl index 955c9e7182..53fb829eb6 100644 --- a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl @@ -437,3 +437,22 @@ run "omits_optional_kms_statements" { error_message = "Null Parameter Store and build-queue keys must omit every optional scale-runner KMS statement." } } + +run "requires_job_retry_queue_when_enabled" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + config = merge(var.config, { + job_retry = merge(var.config.job_retry, { + enabled = true + queue = null + }) + }) + } + + expect_failures = [terraform_data.validate_config] +} diff --git a/modules/orchestration-providers/webhook/scale-runners/validations.tf b/modules/orchestration-providers/webhook/scale-runners/validations.tf new file mode 100644 index 0000000000..50cdc1a136 --- /dev/null +++ b/modules/orchestration-providers/webhook/scale-runners/validations.tf @@ -0,0 +1,8 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + 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." + } + } +} diff --git a/modules/orchestration-providers/webhook/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf index f44c063e9d..3910d7ea66 100644 --- a/modules/orchestration-providers/webhook/scale-runners/variables.tf +++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf @@ -195,11 +195,6 @@ variable "config" { }) 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" { diff --git a/modules/orchestration-providers/webhook/scale-runners/versions.tf b/modules/orchestration-providers/webhook/scale-runners/versions.tf index da9769f550..3ef011ea0a 100644 --- a/modules/orchestration-providers/webhook/scale-runners/versions.tf +++ b/modules/orchestration-providers/webhook/scale-runners/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl index 105648e8be..71da6daf12 100644 --- a/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl +++ b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl @@ -289,3 +289,23 @@ run "owns_webhook_control_plane" { error_message = "Pool and job-retry leaf ownership must remain inside the webhook provider." } } + +run "rejects_conflicting_artifact_sources" { + command = plan + + plan_options { + target = [terraform_data.validate_config] + } + + variables { + config = merge(var.config, { + lambda = merge(var.config.lambda, { + artifact = merge(var.config.lambda.artifact, { + zip = "runners.zip" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_config] +} diff --git a/modules/orchestration-providers/webhook/validations.tf b/modules/orchestration-providers/webhook/validations.tf new file mode 100644 index 0000000000..0f4d5213f9 --- /dev/null +++ b/modules/orchestration-providers/webhook/validations.tf @@ -0,0 +1,11 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + condition = !( + var.config.lambda.artifact.zip != null && + var.config.lambda.artifact.s3 != null + ) + error_message = "config.lambda.artifact must select at most one of zip or s3." + } + } +} diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf index 50f3baf2d3..1ecc898ef4 100644 --- a/modules/orchestration-providers/webhook/variables.tf +++ b/modules/orchestration-providers/webhook/variables.tf @@ -17,7 +17,7 @@ variable "tags" { variable "config" { description = <<-EOT - Provider-owned webhook values supplied from `orchestration.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks. + Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks. - `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration. - `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. @@ -150,14 +150,6 @@ variable "config" { }) }) nullable = false - - validation { - condition = !( - var.config.lambda.artifact.zip != null && - var.config.lambda.artifact.s3 != null - ) - error_message = "config.lambda.artifact must select at most one of zip or s3." - } } variable "runner" { diff --git a/modules/orchestration-providers/webhook/versions.tf b/modules/orchestration-providers/webhook/versions.tf index da9769f550..3ef011ea0a 100644 --- a/modules/orchestration-providers/webhook/versions.tf +++ b/modules/orchestration-providers/webhook/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index e5711ad414..c68133115b 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -6,9 +6,9 @@ This internal module implements the experimental provider-neutral runner configu The module selects the [`webhook` orchestration provider](../orchestration-providers/webhook), which owns its [`scale-runners`](../orchestration-providers/webhook/scale-runners), [`pool`](../orchestration-providers/webhook/pool), and [`job-retry`](../orchestration-providers/webhook/job-retry) leaves. The configuration module retains the common [`ssm-housekeeper`](./ssm-housekeeper), creates or selects the runner IAM role, manages shared runner configuration in SSM, and dispatches the selected compute provider. -Runner demand orchestration is selected independently through `orchestration`. `orchestration.webhook` is the currently supported provider and owns the build queue reference; runner lifecycle, boot time, and capacity under `orchestration.webhook.runner`; runner registration scope; scaling controls; scheduled pool; and job retry. Common `runner` contains no webhook lifecycle or capacity settings. The provider resolves its lifecycle contract before runner-config serializes the existing bootstrap parameters. The provider wrapper is nullable so a future sibling provider can be added without moving this webhook contract again, while validation requires exactly one provider to be selected. +Runner demand orchestration is selected independently through `orchestration_provider`. `orchestration_provider.webhook` is the currently supported provider and owns the build queue reference; runner lifecycle, boot time, and capacity under `orchestration_provider.webhook.runner`; runner registration scope; scaling controls; scheduled pool; and job retry. Common `runner` contains no webhook lifecycle or capacity settings. The provider resolves its lifecycle contract before runner-config serializes the existing bootstrap parameters. The provider wrapper is nullable so a future sibling provider can be added without moving this webhook contract again, while validation requires exactly one provider to be selected. -Common `lambda` contains only shared execution substrate and the optional shared artifact bucket. The webhook provider owns the runner-control archive shared by scale, pool, and job-retry at `orchestration.webhook.lambda.artifact` and combines its zip or S3 key/version with that common substrate. The common SSM housekeeper independently owns `ssm.housekeeper.lambda.artifact`: an S3 selection combines its component key/version with the common bucket, a local zip is used otherwise when configured, and the packaged runner control-plane archive is the final fallback. It never inherits the webhook runner-control archive. +Common `lambda` contains only shared execution substrate and the optional shared artifact bucket. The webhook provider owns the runner-control archive shared by scale, pool, and job-retry at `orchestration_provider.webhook.lambda.artifact` and combines its zip or S3 key/version with that common substrate. The common SSM housekeeper independently owns `ssm.housekeeper.lambda.artifact`: an S3 selection combines its component key/version with the common bucket, a local zip is used otherwise when configured, and the packaged runner control-plane archive is the final fallback. It never inherits the webhook runner-control archive. Provider-owned settings remain nested under a typed provider block. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. `multi-runner` resolves experimental globals and runner-configuration overrides first, then its final forwarding adapter preserves the wrapped `{ ec2 = ... }` object expected by this module. Exactly one provider block must be non-null, and the configuration module derives its provider type from that block rather than from a separate discriminator. @@ -16,9 +16,9 @@ The EC2 block reaches runner-config with `compute_provider.ec2.binaries_syncer = ## Tagging -`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `orchestration.webhook.queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `orchestration.webhook.lambda.scale.up`, `orchestration.webhook.lambda.scale.down`, `orchestration.webhook.lambda.pool`, `orchestration.webhook.job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes. +`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `orchestration_provider.webhook.queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `orchestration_provider.webhook.lambda.scale.up`, `orchestration_provider.webhook.lambda.scale.down`, `orchestration_provider.webhook.lambda.pool`, `orchestration_provider.webhook.job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes. -Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `orchestration.webhook.lambda.scale.up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `orchestration.webhook.lambda.scale.up.tags`. +Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `orchestration_provider.webhook.lambda.scale.up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `orchestration_provider.webhook.lambda.scale.up.tags`. Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and runner-configuration `compute_provider.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. @@ -34,7 +34,7 @@ The scale up lambda is triggered by events on a SQS queue. Events on this queue ### Lambda scale down -The scale down lambda is triggered via a CloudWatch event. The event is triggered by a cron expression defined in `orchestration.webhook.lambda.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. +The scale down lambda is triggered via a CloudWatch event. The event is triggered by a cron expression defined in `orchestration_provider.webhook.lambda.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/orchestration-providers/webhook/scale-down-state-diagram.md:mkdocs_scale_down_state_diagram" @@ -70,7 +70,7 @@ yarn run dist | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers @@ -78,13 +78,14 @@ yarn run dist | Name | Version | |------|---------| | [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | |------|--------|---------| | [compute\_ec2](#module\_compute\_ec2) | ../compute-providers/ec2 | n/a | -| [ec2\_trust\_policy](#module\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | +| [compute\_ec2\_trust\_policy](#module\_compute\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | | [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | | [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | @@ -99,6 +100,7 @@ yarn run dist | [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 | +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | ## Inputs @@ -111,7 +113,7 @@ yarn run dist | [github](#input\_github) | GitHub API and runner-registration configuration.

- `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the 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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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({
artifact = optional(object({
s3 = optional(object({
bucket = 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), {})
principals = optional(list(object({
type = string
identifiers = list(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 | -| [orchestration](#input\_orchestration) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.

- `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
- `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`.
- `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`.
- `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `webhook.queue.build.arn`: ARN of the runner configuration's build queue.
- `webhook.queue.build.url`: URL of the runner configuration's build queue.
- `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key.
- `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`.
- `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive.
- `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null.
- `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null.
- `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null.
- `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`.
- `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`.
- `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`.
- `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`.
- `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`.
- `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`.
- `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`.
- `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`.
- `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`.
- `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`.
- `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. |
object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})
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)
}), {})
}), {})
}), null)
})
| n/a | yes | +| [orchestration\_provider](#input\_orchestration\_provider) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.

- `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
- `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`.
- `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`.
- `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `webhook.queue.build.arn`: ARN of the runner configuration's build queue.
- `webhook.queue.build.url`: URL of the runner configuration's build queue.
- `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key.
- `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`.
- `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive.
- `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null.
- `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null.
- `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null.
- `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`.
- `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`.
- `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`.
- `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`.
- `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`.
- `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`.
- `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`.
- `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`.
- `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`.
- `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`.
- `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. |
object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})
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)
}), {})
}), {})
}), null)
})
| n/a | yes | | [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no | | [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`.
- `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.
- `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.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `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")
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | | [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.

- `paths.root`: Root Parameter Store path for this runner configuration.
- `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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `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.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `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_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
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 | @@ -121,7 +123,7 @@ yarn run dist | Name | Description | |------|-------------| -| [orchestration](#output\_orchestration) | Resources grouped under the selected runner orchestration provider. | +| [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | | [runner](#output\_runner) | Common runner resources. The role is null when an external runner role is used. | diff --git a/modules/runner-config/common-config.tf b/modules/runner-config/common-config.tf index 67b9cc1d07..660fcc60ab 100644 --- a/modules/runner-config/common-config.tf +++ b/modules/runner-config/common-config.tf @@ -41,14 +41,4 @@ locals { ]) } -data "aws_caller_identity" "current" { - lifecycle { - precondition { - condition = ( - var.ssm.housekeeper.lambda.artifact.s3 == null || - var.lambda.artifact.s3.bucket != null - ) - error_message = "lambda.artifact.s3.bucket must be set when ssm.housekeeper.lambda.artifact.s3 is selected." - } - } -} +data "aws_caller_identity" "current" {} diff --git a/modules/runner-config/compute-provider.tf b/modules/runner-config/compute-provider.tf index 9549c2294a..52068c959a 100644 --- a/modules/runner-config/compute-provider.tf +++ b/modules/runner-config/compute-provider.tf @@ -5,7 +5,7 @@ locals { ]) provider_assume_role_policies = { - ec2 = try(module.ec2_trust_policy[0].assume_role_policy, null) + ec2 = try(module.compute_ec2_trust_policy[0].assume_role_policy, null) } provider_assume_role_policy = local.provider_assume_role_policies[local.provider_type] diff --git a/modules/runner-config/ec2.tf b/modules/runner-config/ec2.tf index dc1016a1a1..13c085c0c0 100644 --- a/modules/runner-config/ec2.tf +++ b/modules/runner-config/ec2.tf @@ -1,4 +1,4 @@ -module "ec2_trust_policy" { +module "compute_ec2_trust_policy" { count = local.provider_type == "ec2" ? 1 : 0 source = "../compute-providers/ec2/trust-policy" diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf index 02af4820da..25994beaba 100644 --- a/modules/runner-config/orchestration-provider.tf +++ b/modules/runner-config/orchestration-provider.tf @@ -1,6 +1,6 @@ locals { orchestration_providers = { - for provider_type, provider_config in var.orchestration : provider_type => provider_config + for provider_type, provider_config in var.orchestration_provider : provider_type => provider_config if provider_config != null } @@ -23,7 +23,7 @@ module "orchestration_webhook" { prefix = var.prefix tags = var.tags - config = var.orchestration.webhook + config = var.orchestration_provider.webhook runner = var.runner github = var.github lambda = { diff --git a/modules/runner-config/outputs.tf b/modules/runner-config/outputs.tf index 8c833eab95..47874d4c62 100644 --- a/modules/runner-config/outputs.tf +++ b/modules/runner-config/outputs.tf @@ -20,7 +20,7 @@ output "pool" { value = one(module.orchestration_webhook[*].pool) } -output "orchestration" { +output "orchestration_provider" { description = "Resources grouped under the selected runner orchestration provider." value = { webhook = local.orchestration_provider_enabled.webhook ? { diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index dc262b3d1d..9661074158 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -72,7 +72,7 @@ module "external_iam" { } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 3 @@ -168,7 +168,7 @@ module "generated_policy" { } } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 3 diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl index 7828cc6d11..6cd0dcf6e5 100644 --- a/modules/runner-config/tests/pool.tftest.hcl +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -85,7 +85,7 @@ variables { } } - orchestration = { + orchestration_provider = { webhook = { runner = { boot_time_in_minutes = 8 @@ -142,16 +142,16 @@ run "plan_with_pool_enabled" { && !contains(keys(var.runner), "boot_time_in_minutes") && !contains(keys(var.runner), "ephemeral") && !contains(keys(var.runner), "jit_config_enabled") - && var.orchestration.webhook.runner.boot_time_in_minutes == 8 - && var.orchestration.webhook.runner.ephemeral - && var.orchestration.webhook.runner.jit_config_enabled == null - && var.orchestration.webhook.runner.maximum_count == 9 + && var.orchestration_provider.webhook.runner.boot_time_in_minutes == 8 + && var.orchestration_provider.webhook.runner.ephemeral + && var.orchestration_provider.webhook.runner.jit_config_enabled == null + && var.orchestration_provider.webhook.runner.maximum_count == 9 && module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" && module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" && module.orchestration_webhook[0].pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" && module.orchestration_webhook[0].pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" ) - error_message = "Runner capacity and boot time must be owned by orchestration.webhook.runner and routed to webhook controls, not retained in the common runner contract." + error_message = "Runner capacity and boot time must be owned by orchestration_provider.webhook.runner and routed to webhook controls, not retained in the common runner contract." } assert { @@ -189,8 +189,8 @@ run "plan_with_pool_enabled" { assert { condition = ( - length(module.ec2_trust_policy) == 1 - && aws_iam_role.runner[0].assume_role_policy == module.ec2_trust_policy[0].assume_role_policy + length(module.compute_ec2_trust_policy) == 1 + && aws_iam_role.runner[0].assume_role_policy == module.compute_ec2_trust_policy[0].assume_role_policy ) error_message = "The common runner role must use the selected EC2 trust-policy submodule output." } @@ -205,11 +205,11 @@ run "plan_with_pool_enabled" { assert { condition = ( - toset(keys(output.orchestration)) == toset(["webhook"]) - && output.orchestration.webhook != null - && output.orchestration.webhook.scale_up != null - && output.orchestration.webhook.scale_down != null - && output.orchestration.webhook.pool != null + toset(keys(output.orchestration_provider)) == toset(["webhook"]) + && output.orchestration_provider.webhook != null + && output.orchestration_provider.webhook.scale_up != null + && output.orchestration_provider.webhook.scale_down != null + && output.orchestration_provider.webhook.pool != null ) error_message = "The canonical orchestration output must group the existing webhook control-plane resources while flat aliases remain available." } @@ -358,7 +358,11 @@ run "rejects_conflicting_housekeeper_artifacts" { } } - expect_failures = [var.ssm] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } run "rejects_housekeeper_s3_without_common_bucket" { @@ -390,19 +394,27 @@ run "rejects_housekeeper_s3_without_common_bucket" { } } - expect_failures = [data.aws_caller_identity.current] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } run "rejects_missing_orchestration_provider" { command = plan variables { - orchestration = { + orchestration_provider = { webhook = null } } - expect_failures = [var.orchestration] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } run "external_runner_role_is_not_managed_by_common" { @@ -507,7 +519,11 @@ run "external_role_rejects_managed_policy_attachments" { } } - expect_failures = [var.runner] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } run "external_role_rejects_trust_policy_extension" { @@ -525,7 +541,11 @@ run "external_role_rejects_trust_policy_extension" { } } - expect_failures = [var.runner] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } run "rejects_invalid_trust_policy_extension" { @@ -540,7 +560,11 @@ run "rejects_invalid_trust_policy_extension" { } } - expect_failures = [var.runner] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } run "rejects_empty_compute_provider" { @@ -550,7 +574,11 @@ run "rejects_empty_compute_provider" { compute_provider = {} } - expect_failures = [var.compute_provider] + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] } run "job_retry_uses_common_runner_configuration_identity" { @@ -561,7 +589,7 @@ run "job_retry_uses_common_runner_configuration_identity" { labels = ["self-hosted", "linux", "x64"] name_prefix = "provider-neutral-" } - orchestration = { + orchestration_provider = { webhook = { github = { organization_runners = true diff --git a/modules/runner-config/tests/tags.tftest.hcl b/modules/runner-config/tests/tags.tftest.hcl index 27350d15b1..6d49f89eb8 100644 --- a/modules/runner-config/tests/tags.tftest.hcl +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -89,7 +89,7 @@ variables { } } - orchestration = { + orchestration_provider = { webhook = { github = { organization_runners = true diff --git a/modules/runner-config/validations.tf b/modules/runner-config/validations.tf new file mode 100644 index 0000000000..3cc1a6d125 --- /dev/null +++ b/modules/runner-config/validations.tf @@ -0,0 +1,111 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + condition = contains(["linux", "osx", "windows"], var.runner.os) + error_message = "Valid values for runner.os are linux, osx, and windows." + } + + precondition { + condition = length(var.runner.name_prefix) <= 45 + error_message = "runner.name_prefix must be at most 45 characters." + } + + precondition { + 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." + } + + precondition { + 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." + } + + precondition { + condition = var.runner.iam.additional_trust_policy_json == null ? true : can(jsondecode(var.runner.iam.additional_trust_policy_json)) + error_message = "runner.iam.additional_trust_policy_json must be valid JSON when set." + } + + precondition { + condition = var.runner.iam.role == null || var.runner.iam.additional_trust_policy_json == null + error_message = "runner.iam.additional_trust_policy_json cannot be set with an external runner.iam.role because external role trust is not managed by this module." + } + + precondition { + condition = contains(["arm64", "x86_64"], var.lambda.architecture) + error_message = "lambda.architecture must be arm64 or x86_64." + } + + precondition { + condition = !( + var.ssm.housekeeper.lambda.artifact.zip != null && + var.ssm.housekeeper.lambda.artifact.s3 != null + ) + error_message = "ssm.housekeeper.lambda.artifact must select at most one of zip or s3." + } + + precondition { + condition = ( + var.ssm.housekeeper.lambda.artifact.s3 == null || + var.lambda.artifact.s3.bucket != null + ) + error_message = "lambda.artifact.s3.bucket must be set when ssm.housekeeper.lambda.artifact.s3 is selected." + } + + precondition { + condition = contains(["STANDARD", "INFREQUENT_ACCESS"], var.observability.logs.class) + error_message = "observability.logs.class must be STANDARD or INFREQUENT_ACCESS." + } + + precondition { + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ], var.observability.logs.level) + error_message = "observability.logs.level must be one of silly, trace, debug, info, warn, error, or fatal." + } + + precondition { + 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." + } + + precondition { + condition = length([ + for provider_name, provider_config in var.orchestration_provider : provider_name + if provider_config != null + ]) == 1 + error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook." + } + + precondition { + condition = var.orchestration_provider.webhook == null ? true : ( + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size >= 1 && + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size <= 1000 && + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds >= 0 && + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds <= 300 + ) + error_message = "orchestration_provider.webhook.lambda.scale.up.event_source_mapping batch size must be between 1 and 1000 and its batching window between 0 and 300 seconds." + } + + precondition { + condition = var.orchestration_provider.webhook == null ? true : !( + var.orchestration_provider.webhook.lambda.artifact.zip != null && + var.orchestration_provider.webhook.lambda.artifact.s3 != null + ) + error_message = "orchestration_provider.webhook.lambda.artifact must select at most one of zip or s3." + } + + precondition { + condition = var.orchestration_provider.webhook == null ? true : (!var.orchestration_provider.webhook.job_retry.enabled || var.orchestration_provider.webhook.job_retry.delay_in_seconds <= 900) + error_message = "orchestration_provider.webhook.job_retry.delay_in_seconds cannot exceed the SQS maximum of 900 seconds." + } + } +} diff --git a/modules/runner-config/variables.compute-provider.tf b/modules/runner-config/variables.compute-provider.tf index a8306cad14..de3c6e1f2a 100644 --- a/modules/runner-config/variables.compute-provider.tf +++ b/modules/runner-config/variables.compute-provider.tf @@ -245,11 +245,4 @@ variable "compute_provider" { }), null) }) - validation { - 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." - } } diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf index eec91e9491..6d176ad6d7 100644 --- a/modules/runner-config/variables.orchestration-provider.tf +++ b/modules/runner-config/variables.orchestration-provider.tf @@ -1,5 +1,5 @@ # Typed orchestration-provider input boundary between the common runner configuration and demand controllers. -variable "orchestration" { +variable "orchestration_provider" { description = <<-EOT Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply. @@ -138,34 +138,4 @@ variable "orchestration" { }) nullable = false - validation { - condition = length([ - for provider_name, provider_config in var.orchestration : provider_name - if provider_config != null - ]) == 1 - error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook." - } - - validation { - condition = var.orchestration.webhook == null ? true : ( - var.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size >= 1 && - var.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size <= 1000 && - var.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds >= 0 && - var.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds <= 300 - ) - error_message = "orchestration.webhook.lambda.scale.up.event_source_mapping batch size must be between 1 and 1000 and its batching window between 0 and 300 seconds." - } - - validation { - condition = var.orchestration.webhook == null ? true : !( - var.orchestration.webhook.lambda.artifact.zip != null && - var.orchestration.webhook.lambda.artifact.s3 != null - ) - error_message = "orchestration.webhook.lambda.artifact must select at most one of zip or s3." - } - - validation { - condition = var.orchestration.webhook == null ? true : (!var.orchestration.webhook.job_retry.enabled || var.orchestration.webhook.job_retry.delay_in_seconds <= 900) - error_message = "orchestration.webhook.job_retry.delay_in_seconds cannot exceed the SQS maximum of 900 seconds." - } } diff --git a/modules/runner-config/variables.tf b/modules/runner-config/variables.tf index f2d2cba5ab..9315eb48c7 100644 --- a/modules/runner-config/variables.tf +++ b/modules/runner-config/variables.tf @@ -69,35 +69,6 @@ variable "runner" { }), {}) }) - validation { - condition = contains(["linux", "osx", "windows"], var.runner.os) - error_message = "Valid values for runner.os are linux, osx, and windows." - } - - validation { - condition = length(var.runner.name_prefix) <= 45 - error_message = "runner.name_prefix must be at most 45 characters." - } - - 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." - } - - validation { - condition = var.runner.iam.additional_trust_policy_json == null ? true : can(jsondecode(var.runner.iam.additional_trust_policy_json)) - error_message = "runner.iam.additional_trust_policy_json must be valid JSON when set." - } - - validation { - condition = var.runner.iam.role == null || var.runner.iam.additional_trust_policy_json == null - error_message = "runner.iam.additional_trust_policy_json cannot be set with an external runner.iam.role because external role trust is not managed by this module." - } } variable "github" { @@ -161,10 +132,6 @@ variable "lambda" { }) default = {} - validation { - condition = contains(["arm64", "x86_64"], var.lambda.architecture) - error_message = "lambda.architecture must be arm64 or x86_64." - } } variable "ssm" { @@ -225,13 +192,6 @@ variable "ssm" { }), {}) }) - validation { - condition = !( - var.ssm.housekeeper.lambda.artifact.zip != null && - var.ssm.housekeeper.lambda.artifact.s3 != null - ) - error_message = "ssm.housekeeper.lambda.artifact must select at most one of zip or s3." - } } variable "observability" { @@ -277,21 +237,4 @@ variable "observability" { }) default = {} - validation { - condition = contains(["STANDARD", "INFREQUENT_ACCESS"], var.observability.logs.class) - error_message = "observability.logs.class must be STANDARD or INFREQUENT_ACCESS." - } - - validation { - condition = contains([ - "silly", - "trace", - "debug", - "info", - "warn", - "error", - "fatal", - ], var.observability.logs.level) - error_message = "observability.logs.level must be one of silly, trace, debug, info, warn, error, or fatal." - } } diff --git a/modules/runner-config/versions.tf b/modules/runner-config/versions.tf index da9769f550..3ef011ea0a 100644 --- a/modules/runner-config/versions.tf +++ b/modules/runner-config/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.3.0" + required_version = ">= 1.4.0" required_providers { aws = { From 5e07ae709addf648f378779cf977d7c2163a73bf Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sun, 16 Aug 2026 00:32:23 +0200 Subject: [PATCH 30/49] fix(multi-runner): preserve queue policy behavior --- ...-runner-orchestration-provider-boundary.md | 2 +- modules/multi-runner/README.md | 3 +- modules/multi-runner/queues.tf | 39 +++---------------- 3 files changed, 8 insertions(+), 36 deletions(-) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index 10d9617796..ed758bcb77 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -294,7 +294,7 @@ Implementation and review must verify the boundary at several levels. - Null SSM or queue KMS keys omit their IAM statements. - Queue and Parameter Store KMS permissions remain separate and use the least actions required. - SSM writes are limited to the configured token and runner-configuration paths. -- Any wildcard IAM resource has an AWS API limitation documented next to it. +- Existing wildcard queue-policy behavior remains unchanged by this refactor; new provider IAM permissions use exact resources unless AWS lacks resource scoping. ### Compatibility checks diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 4c397b8a47..297c2555f8 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -194,8 +194,7 @@ module "multi-runner" { | [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [terraform_data.validate_experimental](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | -| [aws_iam_policy_document.deny_insecure_transport_build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.deny_insecure_transport_build_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index 2b27d9df40..e7f6298bcc 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -1,6 +1,4 @@ -data "aws_iam_policy_document" "deny_insecure_transport_build" { - for_each = local.webhook_runner_config - +data "aws_iam_policy_document" "deny_insecure_transport" { statement { sid = "DenyInsecureTransport" @@ -15,7 +13,9 @@ data "aws_iam_policy_document" "deny_insecure_transport_build" { "sqs:*" ] - resources = [aws_sqs_queue.queued_builds[each.key].arn] + resources = [ + "*" + ] condition { test = "Bool" @@ -50,7 +50,7 @@ resource "aws_sqs_queue" "queued_builds" { resource "aws_sqs_queue_policy" "build_queue_policy" { for_each = local.webhook_runner_config queue_url = aws_sqs_queue.queued_builds[each.key].id - policy = data.aws_iam_policy_document.deny_insecure_transport_build[each.key].json + policy = data.aws_iam_policy_document.deny_insecure_transport.json } resource "aws_sqs_queue" "queued_builds_dlq" { @@ -67,35 +67,8 @@ resource "aws_sqs_queue" "queued_builds_dlq" { ) } -data "aws_iam_policy_document" "deny_insecure_transport_build_dlq" { - for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled } - - statement { - sid = "DenyInsecureTransport" - - effect = "Deny" - - principals { - type = "AWS" - identifiers = ["*"] - } - - actions = [ - "sqs:*" - ] - - resources = [aws_sqs_queue.queued_builds_dlq[each.key].arn] - - condition { - test = "Bool" - variable = "aws:SecureTransport" - values = ["false"] - } - } -} - resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { for_each = { for config, values in local.webhook_runner_config : config => values if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled } queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id - policy = data.aws_iam_policy_document.deny_insecure_transport_build_dlq[each.key].json + policy = data.aws_iam_policy_document.deny_insecure_transport.json } From 18ddc3a1bcebe5db35a94e95e0bd76a08b411ba0 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sun, 16 Aug 2026 00:45:20 +0200 Subject: [PATCH 31/49] docs(adr): remove warm-pool references --- docs/adr/002-runner-orchestration-provider-boundary.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index ed758bcb77..d8f6330fdd 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -310,6 +310,3 @@ Before an existing experimental deployment adopts the module rename, its plan mu - [Experimental compute-provider refactor](../modules/internal/compute-provider-refactor.md) - [GitHub Actions Runner Scale Set reference implementation](https://github.com/actions/scaleset) -- [PR #5204 warm-pool proposal and ADR structure](https://github.com/github-aws-runners/terraform-aws-github-runner/pull/5204) -- [ADR-001 warm-pool decision in PR #5204](https://github.com/github-aws-runners/terraform-aws-github-runner/blob/feature/warm-pool-hibernation/docs/adr/001-warm-pool-hibernation.md) -- [ADR-001 warm-pool implementation plan in PR #5204](https://github.com/github-aws-runners/terraform-aws-github-runner/blob/feature/warm-pool-hibernation/docs/adr/001-warm-pool-implementation-plan.md) From a4a7792a45862eeb09735fb0909999299ec5c8a2 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sun, 16 Aug 2026 01:21:09 +0200 Subject: [PATCH 32/49] docs(orchestration): align provider boundary guidance --- ...-runner-orchestration-provider-boundary.md | 110 +++++++-------- docs/index.md | 2 +- .../internal/compute-provider-refactor.md | 130 +++++++++--------- modules/multi-runner/README.md | 2 +- 4 files changed, 117 insertions(+), 127 deletions(-) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index d8f6330fdd..0dc43b7c61 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -10,9 +10,9 @@ Proposed ## Context -The multi-runner module currently receives workflow-job demand through a shared GitHub webhook. A build queue then invokes scale-up, while scheduled Lambda functions handle scale-down, a runner pool, and queued-job retries. Those components evolved together and their settings are spread across shared module inputs and each runner entry. +Before this change, the multi-runner module received workflow-job demand through a shared GitHub webhook. A build queue invoked scale-up, schedules invoked scale-down and the runner pool, and a retry queue invoked queued-job checks. Those components evolved together and their settings were spread across shared module inputs and each runner config. -That layout assumes every runner configuration uses the same demand-control model. It also makes the runner configuration module responsible for webhook-specific resources. Adding another model would require provider conditionals throughout the module or a second copy of the common runner and compute-provider wiring. +That layout assumes every runner config uses the same demand-control model. It also makes the runner-config module responsible for webhook-specific resources. Adding another model would require provider conditionals throughout the module or a second copy of the common runner and compute-provider wiring. GitHub Actions Runner Scale Sets require a different control model. A future implementation is expected to use the runner scale-set and agent APIs, including: @@ -25,21 +25,21 @@ The Terraform contract should make that future addition possible without moving ## Terminology -- **Runner configuration**: One entry in `experimental.multi_runner_config`, including common runner behavior, one orchestration provider, and one compute provider. +- **Runner config**: One entry in `experimental.multi_runner_config`, including common runner behavior, one orchestration provider, and one compute provider. - **Orchestration provider**: The implementation that receives or reconciles runner demand and owns the control components needed to turn that demand into capacity actions. - **Compute provider**: The implementation that creates and manages runner capacity, such as EC2. It supplies capabilities to the selected orchestration provider. - **Webhook orchestration**: The existing webhook, queue, scale-up, scale-down, pool, and job-retry implementation. - **Scale-set orchestration**: A future stateful controller built on GitHub's runner scale-set APIs. -The public contract and documentation use “runner configuration.” They do not introduce a separate nickname for the existing implementation. +This ADR uses “runner config” for the concept and `runner-config` for the module. ## Decision -We will introduce a typed orchestration-provider boundary in the experimental multi-runner v2 interface. +We will use typed orchestration-provider and compute-provider boundaries in the experimental multi-runner v2 interface. Every runner config selects exactly one provider of each type. -### Provider selection is per runner configuration +### Provider selection is per runner config -Every experimental runner configuration must contain an `orchestration_provider` object with exactly one non-null typed provider block. In this phase the only supported block is `webhook`: +Every experimental runner config must contain an `orchestration_provider` object with exactly one non-null typed provider block. In this phase the only supported block is `webhook`: ```hcl experimental = { @@ -74,13 +74,13 @@ experimental = { } ``` -Selection is based on the populated provider block, not on a string discriminator. The wrapper's nullness must be known during planning because it determines the Terraform graph. Values inside the selected provider may remain unknown until apply. +Selection is based on the populated provider block, not on a string discriminator. The wrapper's nullness and any value that controls resource shape must be known during planning. Other values inside the selected provider may remain unknown until apply. -Validation counts non-null provider blocks rather than naming one special case. A future provider can therefore be added as a sibling without changing the selection rule. Different runner configurations may select different providers once more than one exists, but one runner configuration cannot combine providers. +Validation counts non-null provider blocks rather than naming one special case. A future provider can therefore be added as a sibling without changing the selection rule. Different runner configs may select different providers once more than one exists, but one runner config cannot combine providers. ### Global orchestration blocks provide defaults; they do not select providers -`experimental.orchestration_provider.webhook` is the global defaults and shared-component namespace for webhook orchestration. Its presence does not select webhook orchestration for every runner configuration. Selection remains under `experimental.multi_runner_config..orchestration_provider`. +`experimental.orchestration_provider.webhook` is the global defaults and shared-component namespace for webhook orchestration. Its presence does not select webhook orchestration for every runner config. Selection remains under `experimental.multi_runner_config..orchestration_provider`. The webhook global namespace owns: @@ -94,24 +94,24 @@ The webhook global namespace owns: - the runner-control artifact shared by scale, pool, and job-retry; and - default scale-up, scale-down, and pool component settings. -Job-retry remains a per-runner-configuration webhook setting in this phase; its +Job-retry remains a per-runner-config webhook setting in this phase; its typed block supplies its own defaults rather than inheriting a global block. -Runner boot time, ephemeral mode, JIT configuration, and maximum runner count are webhook-provider settings rather than common runner identity. Their canonical paths live under `experimental.orchestration_provider.webhook.runner`, with matching paths under `experimental.multi_runner_config..orchestration_provider.webhook.runner` for runner-configuration overrides. Stable-v1 translation maps the existing lifecycle, boot-time, and capacity inputs into those provider paths, and the unchanged stable `modules/runners` call reads from the canonical provider block. No compatibility aliases are retained under the experimental common `runner` object. The webhook provider resolves a null JIT setting to the effective ephemeral mode, exposes that lifecycle contract to runner-config bootstrap, injects boot time into scale-down and pool, and keeps these settings out of compute-provider capabilities. +Runner boot time, ephemeral mode, JIT configuration, and maximum runner count are webhook-provider settings rather than common runner identity. Their canonical paths live under `experimental.orchestration_provider.webhook.runner`, with matching paths under `experimental.multi_runner_config..orchestration_provider.webhook.runner` for runner-config overrides. Stable-v1 translation maps the existing lifecycle, boot-time, and capacity inputs into those provider paths, and the unchanged stable `modules/runners` call reads from the canonical provider block. No compatibility aliases are retained under the experimental common `runner` object. The webhook provider resolves a null JIT setting to the effective ephemeral mode, exposes that lifecycle contract to runner-config bootstrap, injects boot time into scale-down and pool, and keeps these settings out of compute-provider capabilities. -The common `experimental.github` block continues to own credentials and GitHub API client settings shared across implementations, including `enterprise_server` and `user_agent`. Repository filtering belongs to the shared webhook at `experimental.orchestration_provider.webhook.github.repository_white_list`; per-configuration `organization_runners` remains in the same provider-owned GitHub block. +The common `experimental.github` block continues to own credentials and GitHub API client settings shared across implementations, including `enterprise_server` and `user_agent`. Repository filtering belongs to the shared webhook at `experimental.orchestration_provider.webhook.github.repository_white_list`; per-runner-config `organization_runners` remains in the same provider-owned GitHub block. The common `experimental.lambda` block contains only provider-neutral Lambda substrate: runtime, architecture, networking, role settings, additional principals, tags, and an optional shared artifact bucket. It does not select a provider archive. Each component owner supplies its own local zip or S3 object key and version. -The webhook provider owns one runner-control artifact at `orchestration_provider.webhook.lambda.artifact`, shared by scale, pool, and job-retry. Its `lambda.scale` child contains only `up` and `down` configuration, while the ingress webhook retains its separate `lambda.webhook.artifact`. The provider-neutral SSM housekeeper owns its artifact selector under `ssm.housekeeper.lambda.artifact`. A runner-configuration selection overrides the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the common Lambda artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. This selector is independent of the webhook runner-control artifact. Stable-v1 translation maps the existing runner artifact into both canonical component contracts so the translated representation remains complete without changing the stable resource path. +The webhook provider owns one runner-control artifact at `orchestration_provider.webhook.lambda.artifact`, shared by scale, pool, and job-retry. Its `lambda.scale` child contains only `up` and `down` configuration, while the ingress webhook retains its separate `lambda.webhook.artifact`. The provider-neutral SSM housekeeper owns its artifact selector under `ssm.housekeeper.lambda.artifact`. A runner-config selection overrides the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the common Lambda artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. This selector is independent of the webhook runner-control artifact. Stable-v1 translation maps the existing runner artifact into both canonical component contracts so the translated representation remains complete without changing the stable resource path. For a selected webhook provider, resolution follows: ```text -runner-configuration override > experimental.orchestration_provider.webhook default +runner-config override > experimental.orchestration_provider.webhook default ``` -Tag maps merge from broad to narrow. A runner-configuration override affects only that runner configuration; it does not configure a shared singleton. +Tag maps merge from broad to narrow. A runner-config override affects only that runner config; it does not configure a shared singleton. ### Module ownership follows the provider boundary @@ -119,28 +119,32 @@ The Terraform implementation is split as follows: | Layer | Responsibility | | --- | --- | -| `modules/multi-runner` | Selects stable or experimental mode, resolves global and runner-configuration values, owns shared webhook ingress and build queues, and routes typed provider objects. | +| `modules/multi-runner` | Selects stable or experimental mode, resolves global and runner-config values, owns shared webhook ingress and build queues, and routes typed provider objects. | | `modules/runner-config` | Composes provider-neutral runner resources, selects exactly one orchestration provider and one compute provider, creates or selects the runner role, and connects provider capabilities. | | `modules/orchestration-providers/webhook` | Owns webhook orchestration composition, provider defaults, tag layering, and the scale, pool, and retry leaf modules. | | `modules/orchestration-providers/webhook/scale-runners` | Owns the scale-up and scale-down Lambdas, schedules, queue integration, IAM, and outputs. | | `modules/orchestration-providers/webhook/pool` | Owns optional scheduled pool resources and IAM. | | `modules/orchestration-providers/webhook/job-retry` | Owns optional queued-job retry resources and IAM. | | `modules/runner-config/ssm-housekeeper` | Owns provider-neutral cleanup of runner token and configuration parameters, including its component-specific Lambda artifact. | -| `modules/compute-providers/` | Owns capacity resources and returns policy, environment-variable, trust-policy, and resource capabilities. | +| `modules/compute-providers//trust-policy` | Produces the provider-specific runner-role trust policy before the common runner role is resolved. | +| `modules/compute-providers/` | Owns capacity resources and returns policy, environment-variable, managed-policy, and resource capabilities. | -The former `modules/runner-stack` name becomes `modules/runner-config`. “Runner configuration” describes the module's purpose without implying a specific deployment topology. +The former `modules/runner-stack` name becomes `modules/runner-config`. “Runner config” describes the module's purpose without implying a specific deployment topology. ```mermaid flowchart TD - Multi["multi-runner: normalize and route"] --> Config["runner-config: compose one runner configuration"] + Multi["multi-runner: normalize and route"] --> Config["runner-config: compose one runner config"] Config --> Selector{"exactly one orchestration provider"} Selector --> Webhook["orchestration-providers/webhook"] Selector -. future .-> ScaleSet["orchestration-providers/scale-set"] Config --> ComputeSelector{"exactly one compute provider"} - ComputeSelector --> EC2["compute-providers/ec2"] + ComputeSelector --> Trust["compute-providers/ec2/trust-policy"] + Trust --> Role["runner role"] + Role --> EC2["compute-providers/ec2"] EC2 --> Capabilities["compute capabilities"] - Capabilities --> Webhook - Capabilities -. future .-> ScaleSet + Capabilities --> Adapter["runner-config capability adapter"] + Adapter --> Webhook + Adapter -. future .-> ScaleSet Webhook --> Scale["scale-runners"] Webhook --> Pool["pool"] Webhook --> Retry["job-retry"] @@ -150,23 +154,23 @@ Provider leaf modules live below `modules/orchestration-providers/webhook`, not ### Compute providers expose capabilities, not orchestration resources -The selected compute provider remains independent from the selected orchestration provider. It returns the policy documents, environment variables, trust policy, managed-policy references, and resources needed by orchestration components. +The selected compute provider remains independent from the selected orchestration provider. Its trust-policy submodule supplies the runner-role trust document before the common role is resolved. The full compute provider then returns policy documents, environment variables, managed-policy references, and resources needed by orchestration components. `runner-config` adapts that provider output into the scale-up, scale-down, and pool capabilities consumed by webhook orchestration. The webhook provider owns its Lambda roles and attaches the capability fragments it needs. The compute provider does not create the common runner role or webhook resources. This direction keeps the dependency graph one-way: ```text -runner-config -> compute provider -> capability contract -> orchestration provider +runner-config -> compute provider -> capability contract -> runner-config adapter -> orchestration provider ``` A future scale-set controller may require a different subset or extension of the capability contract. That extension belongs at the provider boundary; it must not add scale-set conditionals to the webhook leaves. ### Compatibility and state are explicit -Stable inputs are translated into the same internal canonical representation so defaults and shared singleton values have one resolution path. Stable runner configurations continue to call the existing `modules/runners` implementation at their existing addresses. Opting into experimental v2 is module-wide: a non-empty `experimental.multi_runner_config` replaces, rather than merges with, the stable map. +Stable inputs are translated into the same internal canonical representation so defaults and shared singleton values have one resolution path. Stable runner configs continue to call the existing `modules/runners` implementation at their existing addresses. Opting into experimental v2 is module-wide: a non-empty `experimental.multi_runner_config` replaces, rather than merges with, the stable map. -The runner configuration uses one explicitly named, count-addressed module per concrete provider. Compute modules follow `module.compute_[0]`, while orchestration modules follow `module.orchestration_[0]`. This avoids duplicating the provider name in addresses such as `module.webhook["webhook"]` and gives future providers symmetric addresses such as `module.compute_microvm[0]` and `module.orchestration_scale_set[0]`. +The runner config uses one explicitly named, count-addressed module per concrete provider. Compute modules follow `module.compute_[0]`, while orchestration modules follow `module.orchestration_[0]`. The experimental v2 implementation does not retain declarative moves from earlier unpublished module names. Those addresses are not part of the stable contract. An existing experimental deployment must migrate any affected state explicitly before upgrading or accept Terraform's proposed replacement actions. @@ -176,29 +180,15 @@ This ADR does not define an automatic stable-v1-to-v2 state migration. Existing ### Semantic validation lives beside module composition -Internal runner-config, orchestration-provider, and compute-provider modules keep nested variable declarations focused on types, defaults, and documentation. Their semantic and cross-field checks live in `validations.tf` as lifecycle preconditions on one empty `terraform_data.validate_config` resource per module. The resource has no `input` or `triggers_replace`, so configuration values are not copied into state and ordinary value changes do not replace it. +Internal runner-config, orchestration-provider, and compute-provider modules keep nested variable declarations focused on types, defaults, and documentation. A module with semantic or cross-field checks declares one or more input-free `terraform_data` validation resources in `validations.tf`. These resources have no `input` or `triggers_replace`, so configuration values are not copied into state and ordinary value changes do not replace them. -This convention requires Terraform 1.4 or newer and adds one state-only validation resource for each instantiated internal module. Known invalid values still fail during planning; unknown conditions may defer until apply, and targeted plans can omit a detached validation resource. Direct module tests therefore target and assert the validation resource explicitly. - -### IAM and encryption follow resource ownership - -Provider-owned IAM policies use conditional statements for optional KMS keys. A null key omits the statement; policies do not use placeholder account IDs, key IDs, or ARNs to satisfy Terraform typing. - -Parameter Store and queue encryption are separate concerns: - -| Key purpose | Consumer | Required KMS actions | -| --- | --- | --- | -| GitHub App parameters in Parameter Store | Scale-up, scale-down, pool, and job retry as applicable | `kms:Decrypt` | -| Encrypted build queue | Scale-up | `kms:Decrypt` | -| Encrypted build queue | Job retry when publishing a retry | `kms:Decrypt`, `kms:GenerateDataKey` | - -Because queue KMS values are used as IAM `Resource` entries, the experimental queue contract requires a KMS key ARN when a customer-managed key is selected. SSM write access is scoped to the runner token and configuration paths. Wildcard resources are allowed only for AWS APIs that do not support resource-level permissions, such as the required X-Ray actions, and the policy must document that reason. +This convention requires Terraform 1.4 or newer and adds state-only objects where an internal module needs semantic validation. Known invalid values still fail during planning; unknown conditions may defer until apply, and targeted plans can omit a detached validation resource. Direct module tests therefore target and assert the relevant validation resource explicitly. ### Existing shared modules stay unchanged -This refactor does not change `modules/webhook` or `modules/ssm`. +The orchestration-provider boundary does not change the public contracts or resource addresses of `modules/webhook` or `modules/ssm`. -The shared webhook remains at its existing unconditional module address. The shared SSM module continues to create or reference the webhook secret even when no runner configuration selects webhook orchestration. That singleton contract may support other uses and is independent of the per-runner exact-one provider selection. +The shared webhook remains at its existing unconditional module address. The shared SSM module continues to create or reference the webhook secret even when no runner config selects webhook orchestration. That singleton contract may support other uses and is independent of the per-runner exact-one provider selection. Any later proposal to make those shared modules conditional is a separate compatibility and state decision. @@ -217,16 +207,16 @@ A follow-up design must decide at least: 7. the capabilities required from each compute provider; and 8. Terraform migration and coexistence behavior when the new provider is enabled. -The intended end state permits webhook and scale-set orchestration in the same multi-runner module instance when different runner configurations select them. It does not permit both controllers to own the same runner configuration. +The intended end state permits webhook and scale-set orchestration in the same multi-runner module instance when different runner configs select them. It does not permit both controllers to own the same runner config. ## Consequences ### Positive - A future orchestration provider becomes a sibling module instead of a cross-cutting conditional. -- Runner and compute-provider configuration remains reusable across demand-control models. +- Common runner settings and compute-provider configuration remain reusable across demand-control models. - Provider-owned queue, Lambda, artifact, IAM, and output settings have one discoverable namespace. -- Exact-one validation prevents ambiguous ownership of a runner configuration. +- Exact-one validation prevents ambiguous ownership of a runner config. - Stable behavior and shared singleton addresses remain unchanged. - Provider module addresses follow one consistent compute and orchestration naming convention. @@ -277,36 +267,32 @@ Implementation and review must verify the boundary at several levels. ### Terraform contract tests -- A runner configuration with exactly one webhook provider plans successfully. -- Zero or multiple non-null orchestration providers fail with a focused validation message. -- Provider-wrapper nullness may shape the plan while values inside the selected provider may be unknown until apply. +- A runner config with exactly one webhook provider plans successfully. +- Zero selected orchestration providers fail with a focused validation message. Once a second typed provider is introduced, selecting multiple providers must fail the same rule. +- Provider-wrapper nullness and other graph-shaping values must be plan-known; non-shaping values inside the selected provider may remain unknown until apply. - Per-runner values override global webhook defaults, and omitted nullable values inherit them. - Shared singleton resources consume global values rather than arbitrary per-runner overrides. - Stable inputs preserve stable resource addresses and output shape. - Canonical runner-config, compute-provider, and orchestration-provider addresses are used consistently in fresh plans. - Canonical nested outputs and compatibility aliases reference the same resources. -### Provider and IAM tests +### Provider integration tests - `runner-config` routes only the selected orchestration provider. - The webhook root composes scale, pool, and retry leaves with the resolved values supplied by `multi-runner`; it does not invent fallback ARNs or empty resource objects. - Compute-provider capability fragments reach the correct webhook component. -- Null SSM or queue KMS keys omit their IAM statements. -- Queue and Parameter Store KMS permissions remain separate and use the least actions required. -- SSM writes are limited to the configured token and runner-configuration paths. -- Existing wildcard queue-policy behavior remains unchanged by this refactor; new provider IAM permissions use exact resources unless AWS lacks resource scoping. +- Existing shared queue-policy and compute-provider IAM behavior remains unchanged by this refactor. ### Compatibility checks -- `modules/webhook` has no diff. -- `modules/ssm` has no diff. +- Existing `modules/webhook` and `modules/ssm` public contracts and resource addresses remain unchanged by the provider boundary. - Stable multi-runner tests continue to pass. -- Experimental provider-routing, computed-input, runner-config, webhook-provider, scale, pool, retry, and SSM-housekeeper tests pass. +- Experimental provider-routing, computed-input, runner-config, webhook-provider, scale, pool, retry, SSM-housekeeper, EC2-provider, and EC2 trust-policy tests pass. - Terraform formatting, documentation generation, and repository pre-commit checks are clean. -Before an existing experimental deployment adopts the module rename, its plan must be inspected for only the expected moved addresses. Stable deployments must not enable v2 until a stable-to-v2 migration procedure exists. +Before an existing experimental deployment adopts the module rename, operators must migrate affected state explicitly and confirm that the resulting plan contains no unintended replacement actions. Stable deployments must not enable v2 until a stable-to-v2 migration procedure exists. ## References -- [Experimental compute-provider refactor](../modules/internal/compute-provider-refactor.md) -- [GitHub Actions Runner Scale Set reference implementation](https://github.com/actions/scaleset) +- [Experimental orchestration- and compute-provider refactor](../modules/internal/compute-provider-refactor.md) +- [GitHub Actions Runner Scale Set client](https://github.com/actions/scaleset) diff --git a/docs/index.md b/docs/index.md index 8fbe402bf1..d16bb7a3cb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -117,7 +117,7 @@ The shared webhook, runner configurations, SSM housekeepers, runner-binary synce Global `ssm.paths.root` is the base for shared and runner-configuration-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the runner-configuration key only for configuration-owned paths. The default derived base is `/github-action-runners/${prefix}`, and runner token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration; it does not select encryption for runtime-created runner parameters. Webhook-provider leaves conditionally omit their KMS statements when this value is null, while apply-time-unknown key ARNs remain valid during planning. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than per-configuration overrides. -Each runner configuration selects two independent typed providers: one `orchestration_provider` provider for demand control and one `compute_provider` for runner capacity. `orchestration_provider.webhook` is the sole supported orchestration provider today. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider objects reach runner-config, which validates each exact-one selection and dispatches the matching root-level provider module. `orchestration-providers/webhook` owns scale-up, scale-down, scheduled pool, job retry, and their webhook-specific defaults and tag layering; runner-config retains common SSM housekeeping plus 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 modules are internal experimental implementation boundaries, not standalone public entry points. Later releases can add mutually exclusive orchestration siblings without changing the common or compute-provider contracts. 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. +Each runner configuration selects two independent typed providers: one `orchestration_provider` provider for demand control and one `compute_provider` for runner capacity. `orchestration_provider.webhook` is the sole supported orchestration provider today. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider objects reach runner-config, which validates each exact-one selection and dispatches the matching root-level provider module. `orchestration-providers/webhook` owns scale-up, scale-down, scheduled pool, job retry, and their webhook-specific defaults and tag layering; runner-config retains common SSM housekeeping plus 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 modules are internal experimental implementation boundaries, not standalone public entry points. Later releases can add mutually exclusive orchestration siblings without changing the common or compute-provider contracts. See the [experimental orchestration- and 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 06429422e0..533a5c4bc2 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -1,4 +1,4 @@ -# Experimental compute-provider refactor +# Experimental orchestration- and compute-provider refactor !!! warning "Experimental opt-in" @@ -6,49 +6,49 @@ ## 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 scale-up, scale-down, pool, job-retry, queue, SSM housekeeping, and GitHub registration workflows are not inherently EC2-specific. The legacy `runners` module combines webhook demand orchestration with EC2 launch templates, instance profiles, bootstrap parameters, log groups, IAM permissions, and Lambda environment variables. Adding another orchestration or 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. +The refactor introduces two typed boundaries. An orchestration provider owns the demand-control model and its components; a compute provider owns runner capacity and exports capabilities consumed by that orchestration. A future scale-set controller or MicroVM backend can therefore be added without moving public provider-owned settings or scattering provider conditionals through leaf modules; the central typed schema, normalization, routing, and dispatch still require extension. ## Ownership model -The implementation is split into demand-orchestration selection, provider-neutral control-plane components, and compute-provider implementations: +The implementation is split into common runner-config composition, orchestration-provider components, and compute-provider implementations: | Layer | Owns | | --- | --- | -| `multi-runner` | Module-level v1/v2 mode selection, flat/nested input projection, canonical global/runner-configuration resolution, typed orchestration and compute-provider routing, configuration keys, webhook build queues and matching, and runner-binary discovery. | -| `runner-config` | Typed orchestration and compute-provider dispatch, shared runner configuration in SSM, the SSM housekeeper, and the common runner role and policy attachments. | -| `orchestration-providers/webhook` | Webhook-provider selection contract, defaults and tag layering, plus composition of the provider-owned control-plane leaves. | -| `orchestration-providers/webhook/scale-runners` | Provider-neutral scale-up and scale-down Lambdas, schedules and queue integration, and their execution roles and policies. | +| `multi-runner` | Module-level v1/v2 mode selection, flat/nested input projection, canonical global/runner-config resolution, typed orchestration- and compute-provider routing, config keys, webhook build queues and matching, and runner-binary discovery. | +| `runner-config` | Typed orchestration- and compute-provider dispatch, shared runner bootstrap config in SSM, the SSM housekeeper, and either a module-managed common runner role with policy attachments or selection of an external runner role. | +| `orchestration-providers/webhook` | Webhook defaults and tag layering, plus composition of the provider-owned control-plane leaves. | +| `orchestration-providers/webhook/scale-runners` | Compute-provider-neutral scale-up and scale-down Lambdas, schedules and queue integration, and their execution roles and policies. | | `orchestration-providers/webhook/pool` | Optional scheduled runner-pool resources and their Lambda and IAM wiring. | | `orchestration-providers/webhook/job-retry` | Optional queued-job retry resources and their Lambda and IAM wiring. | | `runner-config/ssm-housekeeper` | Parameter Store cleanup Lambda, schedule, logging, and IAM resources. | | `compute-providers//trust-policy` | Provider-specific default runner-role trust, merged with the optional caller-provided trust document before the common role is created. | | `compute-providers/` | Provider-specific resources, permission requirements, and the IAM and environment-variable fragments consumed by the common control plane after the runner role is resolved. | -The EC2 provider 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 in this phase. +The EC2 provider owns the instance profile, launch template, security group, AMI and EC2-specific bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. EC2 is the only implemented Terraform compute provider in this phase. Runner-config, the root orchestration and compute providers, and their leaf modules are internal implementation boundaries rather than standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config`; `multi-runner` calls `runner-config`, which selects the provider modules. Their direct input and output contracts may change while v2 remains experimental. -Each external v2 runner configuration selects demand orchestration separately from its compute provider. The required `orchestration_provider` wrapper has one supported provider today: `experimental.multi_runner_config..orchestration_provider.webhook`. It owns the runner configuration's maximum runner count, registration scope, matcher, build-queue overrides, scale-up, scale-down, pool, and job-retry settings. The wrapper is intentionally typed as a provider boundary so later orchestration implementations can be added as mutually exclusive siblings without moving common configuration fields again. +Each external v2 runner config selects demand orchestration separately from its compute provider. The required `orchestration_provider` wrapper has one supported provider today: `experimental.multi_runner_config..orchestration_provider.webhook`. It owns the runner config's lifecycle and maximum runner count, registration scope, matcher, build-queue overrides, scale-up, scale-down, pool, and job-retry settings. The wrapper is intentionally typed as a provider boundary so later orchestration implementations can be added as mutually exclusive siblings without moving common settings again. -The runner configuration also populates exactly one typed compute-provider block, such as `experimental.multi_runner_config..compute_provider.ec2`; that block's presence must be known during planning because it determines capacity routing. Multi-runner module validation enforces both selections through resource preconditions, while each provider implementation owns its provider-specific semantic validation. +The runner config also populates exactly one typed compute-provider block, such as `experimental.multi_runner_config..compute_provider.ec2`; that block's presence must be known during planning because it determines capacity routing. Multi-runner resource preconditions enforce both selections and the public contract's cross-scope and plan-shaping rules, while each provider implementation validates its resolved internal contract. -After resolving global `experimental.compute_provider.ec2` values with the selected runner configuration's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-config`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-config validates that exactly one compute-provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It independently validates the exact-one `orchestration_provider = { webhook = { ... } }` wrapper and invokes the selected root orchestration provider with provider-neutral common objects such as `runner`, the Lambda substrate, SSM, observability, and the selected compute-provider capabilities. +After resolving global `experimental.compute_provider.ec2` values with the selected runner config's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-config`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-config validates that exactly one compute-provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It independently validates the exact-one `orchestration_provider = { webhook = { ... } }` wrapper and invokes the selected root orchestration provider with provider-neutral common objects such as `runner`, the Lambda substrate, SSM, observability, and the selected compute-provider capabilities. -Binary discovery is completed before the runner-config call. `config.experimental.translation.tf` enriches the final canonical runner configuration at `compute_provider.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_configs` call then passes that runner configuration's wrapped `compute_provider` object unchanged. Runner-config and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.binaries_syncer`. +Binary discovery is completed before the runner-config call. `config.experimental.translation.tf` enriches the final canonical runner config at `compute_provider.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_configs` call then passes that runner config's wrapped `compute_provider` object unchanged. Runner-config and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.ec2.binaries_syncer`. -Runner-config creates or selects the runner IAM role, but the current EC2 provider owns the role's default trust-policy document. Each provider implementation supplies a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. Runner-config uses the isolated trust-policy output when it creates the runner role, attaches runner policies itself, and passes the scale-up, scale-down, and pool capabilities to the selected orchestration provider. A provider never creates or attaches the common runner IAM role. +Runner-config creates or selects the runner IAM role, but the current EC2 provider owns the role's default trust-policy document. Each provider implementation supplies a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. When runner-config creates the runner role, it uses the isolated trust-policy output and attaches the returned runner policies. An external role bypasses both operations, so its caller owns trust and permissions. Runner-config passes the scale-up, scale-down, and pool capabilities to the selected orchestration provider in either case. A provider never creates or attaches the common runner IAM role. The trust relationship is deliberately rendered by an isolated provider submodule: -1. `multi-runner` validates the runner configuration's typed orchestration and compute-provider selections, resolves its global and per-configuration values, and invokes `runner-config` with both wrapped provider configurations. +1. `multi-runner` validates the runner config's typed orchestration- and compute-provider selections, resolves its global and per-runner-config values, and invokes `runner-config` with both wrapped provider configs. 2. `runner-config` independently derives the orchestration and compute providers from their single non-null typed blocks. 3. `compute-providers//trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input. -4. `runner-config` creates or selects the common runner role from the returned `assume_role_policy`. +4. `runner-config` creates the common runner role from the returned `assume_role_policy`, or selects an external role without applying that trust policy. 5. The full compute provider receives the resolved role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements. 6. The provider returns its nested policy, environment-variable, and resource contract. -7. Runner-config attaches runner policies, while `orchestration-providers/webhook` attaches scale-up, scale-down, and pool policy fragments to the roles it owns through its leaves. +7. Runner-config attaches runner policies only to a module-managed runner role, while `orchestration-providers/webhook` attaches scale-up, scale-down, and pool policy fragments to the roles it owns through its leaves. The trust-policy output depends only on its input documents, not on the full provider resources that consume the runner role. This preserves provider ownership of the trust relationship while keeping the dependency graph one-way. @@ -56,11 +56,11 @@ The trust-policy output depends only on its input documents, not on the full pro Multi-runner produces one canonical consumer representation for both input modes: -1. `config.experimental.translation.tf` selects the module mode and builds `local.raw_translated_experimental`. A non-empty experimental runner-configuration map selects the nested `var.experimental` input for v2; otherwise the file projects flat module globals and stable `multi_runner_config` entries into the same schema for v1. -2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/runner-configuration precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, `orchestration_provider.webhook`, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. -3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, webhook queue event mapping, Lambda artifact and principals, the webhook pool Lambda wrapper, SSM KMS, and each enabled EC2 runner configuration's `compute_provider.ec2.binaries_syncer.s3`. The remaining shared components, webhook queues, and runner implementations consume this final canonical object. +1. `config.experimental.translation.tf` selects the module mode and builds `local.raw_translated_experimental`. A non-empty experimental runner-config map selects the nested `var.experimental` input for v2; otherwise the file projects flat module globals and stable `multi_runner_config` entries into the same schema for v1. +2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/runner-config precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, `orchestration_provider.webhook`, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. +3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, shared Lambda artifacts and principals, the internal build-queue KMS projection and runner-control artifact, SSM KMS, and each enabled EC2 runner config's `compute_provider.ec2.binaries_syncer.s3`. Webhook event-source mapping and pool resolution are already complete in the base object. The remaining shared components, webhook queues, and runner implementations consume the final canonical object. -Stable translation always emits `orchestration_provider.webhook`, but stable runner configurations remain on `module.runners["configuration"]`: `runners.tf` adapts each final canonical runner configuration back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate configuration source. This is not the phase-2 implementation migration to `runner-config`. For v2, `module.runner_configs` directly iterates the gated final runner-configuration map. Its input arguments inline the environment tag and live GitHub App and build-queue references into `orchestration_provider.webhook`, then forward the complete orchestration and compute-provider wrappers. Binary output enrichment and all other derived configuration shaping are already complete in canonical translation. +Stable translation always emits `orchestration_provider.webhook`, but stable runner configs remain on `module.runners[""]`: `runners.tf` adapts each final canonical runner config back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate config source. This is not the phase-2 implementation migration to `runner-config`. For v2, `module.runner_configs` directly iterates the gated final runner-config map. Its adapter passes environment-augmented tags, GitHub settings with live App references, and the resolved Lambda, SSM, and observability inputs at the runner-config top level. It injects the live build queue into the webhook orchestration input, forwards the provider-owned fields accepted by runner-config, and omits `matcherConfig` because the shared webhook consumes it. The wrapped compute-provider object is forwarded unchanged. Binary output enrichment and all other derived config shaping are already complete in canonical translation. ## Phase 1 dispatch and compatibility @@ -72,36 +72,37 @@ flowchart TD Experimental["var.experimental"] --> Select Select -->|No| V1["raw_translated_experimental: project flat v1"] Select -->|Yes| V2["raw_translated_experimental: select nested v2"] - V1 --> Base["translated_experimental_base: defaults and global/configuration resolution"] + V1 --> Base["translated_experimental_base: defaults and global/runner-config resolution"] V2 --> Base Base --> Discovery["Provider selection, runner-binary syncer, and discovery"] Discovery --> Final["translated_experimental: enrich EC2 binaries_syncer.s3"] Final --> Singleton["Shared SSM, webhook, termination watcher, and AMI housekeeper"] Final --> Shared["Webhook build queues and matching"] - Final -->|v1 legacy-argument adapter| Legacy["module.runners[configuration]"] - Final -->|v2 direct module input adaptation| RunnerConfig["module.runner_configs[configuration]"] + Final -->|v1 legacy-argument adapter| Legacy["module.runners[key]"] + Final -->|v2 direct module input adaptation| RunnerConfig["module.runner_configs[key]"] RunnerConfig --> Orchestration["orchestration-providers/webhook"] Orchestration --> Scaling["orchestration-providers/webhook/scale-runners"] Orchestration --> Pool["orchestration-providers/webhook/pool"] Orchestration --> Retry["orchestration-providers/webhook/job-retry"] RunnerConfig --> Housekeeper["runner-config/ssm-housekeeper"] - RunnerConfig --> Trust["compute-providers/provider/trust-policy"] + RunnerConfig --> Trust["compute-providers//trust-policy"] Trust --> Role["common runner role"] Role --> Provider RunnerConfig --> Provider["compute-providers/"] - Provider --> Scaling - Provider --> Pool + Provider --> Contract["compute-provider capability contract"] + Contract --> Adapter["runner-config capability adapter"] + Adapter --> Orchestration ``` -The canonical object gives shared singleton resources one global representation and each webhook orchestration and runner implementation one fully resolved runner-configuration representation: +The canonical object gives shared singleton resources one global representation and each webhook orchestration and runner implementation one fully resolved runner-config representation: -- When `experimental.multi_runner_config` is empty, every key in the stable top-level `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address. +- When `experimental.multi_runner_config` is empty, every key in the stable top-level `multi_runner_config` continues to call `modules/runners` at its historical `module.runners[""]` address. - Flat v1 inputs are projected into `raw_translated_experimental`, resolved into `translated_experimental_base`, finalized as `translated_experimental`, and then adapted by `runners.tf` to the existing child-module arguments. - The v1 translation uses `runners_scale_up_lambda_timeout` for build-queue visibility, preserving the stable flat behavior. - The v1 translation wraps its existing registration scope, matcher, queue, scale, pool, and retry values under `orchestration_provider.webhook`; the stable public input and resource behavior remain unchanged. - Stable queue tagging and the flat `runners_map` output remain unchanged. -- When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-config` at `module.runner_configs["configuration"]`; stable-map entries are not dispatched. -- Experimental v2 uses the canonical `module.runner_configs`, `module.compute_ec2[0]`, and `module.orchestration_webhook[0]` addresses. Earlier experimental addresses are not migrated automatically. +- When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-config` at `module.runner_configs[""]`; stable-map entries are not dispatched. +- Experimental v2 uses `module.runner_configs[""]`; within each entry, the canonical provider child addresses are `module.runner_configs[""].module.compute_ec2_trust_policy[0]`, `module.runner_configs[""].module.compute_ec2[0]`, and `module.runner_configs[""].module.orchestration_webhook[0]`. Earlier experimental addresses are not migrated automatically. - Experimental resources are exposed separately through the nested `runners_map_v2` output. - The maps are not combined. A non-empty v2 map has explicit priority over the stable map. @@ -109,18 +110,18 @@ No v1-to-v2 state move is included in phase 1. Enabling v2 for a module instance ## Opting in -Nested global settings are the source of defaults for v2 runner configurations and the applicable singleton shared components. The GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume translated globals. Every v2 runner configuration must currently select `orchestration_provider.webhook`; no other orchestration provider is implemented. The wrapper is the durable provider boundary for future mutually exclusive siblings. Migrated v2 consumers do not fall back to matching flat inputs; those values seed the stable-mode translation only. The singleton-specific webhook, binary-syncer, termination-watcher, and AMI-housekeeper settings all have nested owners. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Per-configuration values override globals only inside that runner configuration and never configure singleton resources. Nested defaults mirror established v1 behavior, while nullable runner-configuration fields inherit the corresponding global when omitted or set to null. Set a global override only when the value is genuinely shared by every runner configuration, and put configuration-specific differences in that configuration itself. An external `runner.iam.role` is the exception to normal inheritance: inherited managed policies and additional trust policy JSON are suppressed because the module does not manage that role. +Nested global settings are the source of defaults for v2 runner configs and the applicable singleton shared components. The GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume translated globals. Every v2 runner config must currently select `orchestration_provider.webhook`; no other orchestration provider is implemented. The wrapper is the durable provider boundary for future mutually exclusive siblings. Migrated v2 consumers do not fall back to matching flat inputs; those values seed the stable-mode translation only. The singleton-specific webhook, binary-syncer, termination-watcher, and AMI-housekeeper settings all have nested owners. Only module naming (`prefix`), `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Per-runner-config values override globals only inside that runner config and do not replace singleton-owned global settings. Each webhook runner config nevertheless contributes matcher, build-queue, and compute-provider routing data to the shared webhook, while its resolved binary-syncer enablement and OS/architecture determine shared syncer membership. Nested defaults mirror established v1 behavior, while nullable runner-config fields inherit the corresponding global when omitted or set to null. Set a global override only when the value is genuinely shared by every runner config, and put runner-specific differences in that config. An external `runner.iam.role` is the exception to normal inheritance: inherited managed policies and additional trust policy JSON are suppressed because the module does not manage that role. ```hcl module "multi_runner" { source = "github-aws-runners/github-runner/aws//modules/multi-runner" experimental = { - # Base tags for v2 queues and runner configurations and for translated singleton + # Base tags for v2 queues and runner configs and for translated singleton # resources such as shared SSM, webhook, binary syncer, watcher, and AMI # housekeeper. tags = { - Workload = "runner-configurations" + Workload = "runner-configs" ManagedBy = "terraform" } @@ -135,7 +136,7 @@ module "multi_runner" { github = { # Required in v2. These nested values are authoritative for shared SSM - # and every v2 runner configuration. + # and every v2 runner config. app = var.github_app additional_apps = var.additional_github_apps @@ -163,7 +164,7 @@ module "multi_runner" { } # Global defaults are grouped by orchestration provider. This block does - # not select a provider; each runner configuration has its own exact-one + # not select a provider; each runner config has its own exact-one # orchestration selector below. orchestration_provider = { webhook = { @@ -238,7 +239,7 @@ module "multi_runner" { # Global webhook build-queue defaults. Visibility is independent of the # scale-up Lambda timeout and must remain at least six times that - # timeout. Encryption is global-only; runner configurations cannot override it. + # timeout. Encryption is global-only; runner configs cannot override it. queue = { delay_webhook_event = 30 job_queue_retention_in_seconds = 86400 @@ -259,7 +260,7 @@ module "multi_runner" { } } - # Shared resources append app/webhook. Runner configurations append their key. + # Shared resources append app/webhook. Runner configs append their key. ssm = { paths = { root = "/github-actions" @@ -268,8 +269,9 @@ module "multi_runner" { } # This ARN-valued scalar may be unknown until apply. It encrypts shared - # app parameters, configures the webhook, and grants runner-config - # decrypt access. + # app parameters created by this module, configures the webhook, and + # grants runner-config decrypt access. Existing *_ssm references retain + # their external encryption. kms_key_id = aws_kms_key.github_app_parameters.arn parameters = { @@ -308,7 +310,7 @@ module "multi_runner" { # Shared v2 EC2 defaults. This block neither selects EC2 nor supplies # required provider fields. Runner-binary settings are global because each - # syncer is shared by runner configurations with the same OS and architecture. + # syncer is shared by runner configs with the same OS and architecture. compute_provider = { ec2 = { vpc_id = var.vpc_id @@ -406,7 +408,7 @@ module "multi_runner" { # these fields again. orchestration_provider = { webhook = { - # This runner configuration overrides the webhook provider's global cap. + # This runner config overrides the webhook provider's global cap. runner = { boot_time_in_minutes = 7 ephemeral = true @@ -429,7 +431,7 @@ module "multi_runner" { } } - # A runner-configuration root is also a base; this resolves to + # A runner-config root is also a base; this resolves to # /github-actions/high-capacity/arm for this entry. ssm = { paths = { @@ -451,7 +453,7 @@ module "multi_runner" { } } - # Each runner configuration also selects exactly one compute provider and supplies its + # Each runner config also selects exactly one compute provider and supplies its # provider-specific values here. compute_provider = { ec2 = { @@ -466,39 +468,39 @@ module "multi_runner" { ## Inputs, tags, and outputs -The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-configuration map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configurations and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-configuration SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration_provider.webhook`: runner lifecycle, boot time, and maximum count; repository filtering; shared routing and matcher storage; queue defaults and encryption; the runner-control artifact shared by scale, pool, and job-retry; and the ingress webhook, scale, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner configuration's separate `orchestration_provider` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. +The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-config map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configs and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-runner-config SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration_provider.webhook`: runner lifecycle, boot time, and maximum count; repository filtering; shared routing and matcher storage; queue defaults and encryption; the runner-control artifact shared by scale, pool, and job-retry; and the ingress webhook, scale, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner config's separate `orchestration_provider` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. -Each v2 runner configuration groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration_provider` provider wrapper. Its sole supported block today is `orchestration_provider.webhook`, containing provider-owned runner lifecycle, boot-time, and capacity settings, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale.up`, `lambda.scale.down`, `lambda.pool`, and `job_retry`. A nullable per-configuration field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner configuration is therefore a non-null configuration override followed by the global nested value, including that field's nested schema default. Per-configuration precedence does not extend to singleton shared resources: the webhook, runner-binary syncer, termination watcher, AMI housekeeper, and shared GitHub App Parameter Store module consume global translated values only. The runner-binary enable switch is an exception only in that it determines whether its OS/architecture pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. +Each v2 runner config groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration_provider` wrapper. Its sole supported block today is `orchestration_provider.webhook`, containing provider-owned runner lifecycle, boot-time, and capacity settings, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale.up`, `lambda.scale.down`, `lambda.pool`, and `job_retry`. A nullable per-runner-config field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner config is therefore a non-null runner-config override followed by the global nested value, including that field's nested schema default. Per-runner-config precedence does not replace singleton-owned global settings: the shared GitHub App Parameter Store module, webhook implementation and routing, runner-binary syncer settings, termination watcher, and AMI housekeeper use translated globals. The shared webhook nevertheless aggregates each webhook runner config's matcher, build queue, and compute-provider route. A runner config's resolved binary-syncer enablement and OS/architecture determine whether its pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. -Global `experimental.orchestration_provider.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-configuration redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration_provider.webhook.queue` override those global defaults, and runner-configuration queue tags merge over global queue tags. Build-queue visibility is independent from Lambda configuration: `experimental.multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. +Global `experimental.orchestration_provider.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-runner-config redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration_provider.webhook.queue` override those global defaults, and runner-config queue tags merge over global queue tags. Build-queue visibility is independent from Lambda config: `experimental.multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. -Queue encryption is global-only. Omitting the entire `experimental.orchestration_provider.webhook.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue. Runner configurations cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent and are forwarded separately to `orchestration-providers/webhook`: scale-up receives queue-key `kms:Decrypt`, job-retry receives queue-key `kms:Decrypt` and `kms:GenerateDataKey`, and both retain separate Parameter Store decrypt statements. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when it publishes to customer-managed encrypted queues. The v1 translation retains the flat contract: per-configuration delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. +Queue encryption is global-only. Omitting the entire `experimental.orchestration_provider.webhook.queue.encryption` block defaults `sqs_managed_sse_enabled` to `true` and the KMS fields to null, matching flat `queue_encryption`. If callers supply an explicit block, all three leaf keys are required: use explicit nulls for inactive fields, with a non-null SQS-managed switch for the non-KMS mode or a non-null `kms_master_key_id` for KMS mode. It configures the multi-runner build queues and their dead-letter queues, not the webhook provider's separate job-retry queue. Runner configs cannot override encryption. The queue CMK and `experimental.ssm.kms_key_id` are independent and are forwarded separately to `orchestration-providers/webhook`: scale-up receives queue-key `kms:Decrypt`, job-retry receives queue-key `kms:Decrypt` and `kms:GenerateDataKey`, and both retain separate Parameter Store decrypt statements. The existing shared `modules/webhook` contract remains unchanged and still requires caller-supplied key access when it publishes to customer-managed encrypted queues. Build queues and dead-letter queues continue to reuse the existing singleton `DenyInsecureTransport` queue policy; this refactor does not change its wildcard `Resource`. The v1 translation retains the flat contract: per-runner-config delay, retention, redrive, and tags keep their stable sources, build-queue visibility comes from `runners_scale_up_lambda_timeout`, and encryption comes from `queue_encryption`. -Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner configurations: `app` is required and `additional_apps` defaults to `[]`. `experimental.orchestration_provider.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-config client settings. Neither field is a root `experimental` sibling. Per-configuration `orchestration_provider.webhook.github.organization_runners` is the separate registration-scope setting; runner configurations do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. +Global `experimental.github` owns the GitHub App credentials persisted or selected by shared SSM and used by v2 runner configs: `app` is required and `additional_apps` defaults to `[]`. `experimental.orchestration_provider.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` defaults to `true`, and `experimental.github.user_agent` defaults to `github-aws-runners`; both remain runner-config client settings. Neither field is a root `experimental` sibling. Per-runner-config `orchestration_provider.webhook.github.organization_runners` is the separate registration-scope setting; runner configs do not override credentials, repository filtering, the enterprise endpoint, or the User-Agent. -Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner configuration consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. +Shared SSM creates or selects Parameter Store credentials from the authoritative `experimental.github` object, and the webhook and every v2 runner config consume the resulting references. Flat `github_app` and `additional_github_apps` seed only the stable-mode translation and impose no equality requirement in v2. The webhook does not make GitHub API requests. Global `experimental.orchestration_provider.webhook` configures the shared webhook's queue-selection strategy, EventBridge implementation and accepted events, and matcher-configuration Parameter Store tier in addition to its queue and Lambda component defaults. `orchestration_provider.webhook.eventbridge.enable` and the matcher tier must be known during planning because they select module or parameter-chunk shape. `first` deterministically chooses the first equally matched queue by priority, `random` spreads jobs among equals, and `all` sends a job to every match at the cost of multiple runner launches and registrations. -Global `ssm.paths.root` is the base for shared GitHub App and webhook parameters and for every runner configuration. Shared resources append the global-only `ssm.paths.app` and `ssm.paths.webhook` segments, which default to `app` and `webhook`; normalization appends the runner-configuration key only for configuration-owned roots, keeping runner parameters isolated. The derived global base defaults to `/github-action-runners/${prefix}`, while runner token and config segments default to `runners/tokens` and `runners/config`. Global `ssm.tags` augments the base tags on the shared Parameter Store module and also defaults configuration-owned SSM tags; `ssm.parameters.tags` remains specific to configuration-managed and runtime-created runner parameters. The global housekeeper defaults preserve the established schedule, enabled state, Lambda artifact, sizing, and cleanup behavior; a nullable per-configuration field inherits those values. Avoid setting a global `ssm.housekeeper.config.tokenPath` unless every runner configuration is intentionally meant to clean the same path; omitting it lets each runner configuration derive its isolated token path. +Global `ssm.paths.root` is the base for shared GitHub App and webhook parameters and for every runner config. Shared resources append the global-only `ssm.paths.app` and `ssm.paths.webhook` segments, which default to `app` and `webhook`; normalization appends the runner-config key only for runner-config-owned roots, keeping runner parameters isolated. The derived global base defaults to `/github-action-runners/${prefix}`, while runner token and config segments default to `runners/tokens` and `runners/config`. Global `ssm.tags` augments the base tags on the shared Parameter Store module and also defaults runner-config-owned SSM tags; `ssm.parameters.tags` remains specific to Terraform-managed and runtime-created runner parameters. The global housekeeper defaults preserve the established schedule, enabled state, Lambda artifact, sizing, and cleanup behavior; a nullable per-runner-config field inherits those values. Avoid setting a global `ssm.housekeeper.config.tokenPath` unless every runner config is intentionally meant to clean the same path; omitting it lets each runner config derive its isolated token path. -Global `observability` values provide defaults for every runner configuration and configure the applicable shared singleton consumers. Log level, retention, KMS key, class, and tracing configure the webhook, runner-binary syncer, termination watcher, and AMI housekeeper; metrics also configure the termination watcher. The nested defaults preserve established behavior: logs use level `info`, 180-day retention, no customer-managed KMS key, and class `STANDARD`; tracing defaults to no mode with HTTP and error capture disabled; metrics default to disabled in the `GitHub Runners` namespace while the rate-limit, job-retry, Spot-termination, and Spot-warning switches default to enabled. The two Spot switches are global termination-watcher settings and have no per-configuration override. Other nullable runner-configuration observability fields inherit the global value. `observability.logs.tags` remains specific to runner-config-owned log groups; shared singleton functions receive global `tags` and `lambda.tags`. The nullness of `observability.tracing.mode` must be known during planning because it selects X-Ray IAM statements and tracing blocks in runner-config consumers. +Global `observability` values provide defaults for every runner config and configure the applicable shared singleton consumers. Log level, retention, KMS key, class, and tracing configure the webhook, runner-binary syncer, termination watcher, and AMI housekeeper; metrics also configure the termination watcher. The nested defaults preserve established behavior: logs use level `info`, 180-day retention, no customer-managed KMS key, and class `STANDARD`; tracing defaults to no mode with HTTP and error capture disabled; metrics default to disabled in the `GitHub Runners` namespace while the rate-limit, job-retry, Spot-termination, and Spot-warning switches default to enabled. The two Spot switches are global termination-watcher settings and have no per-runner-config override. Other nullable runner-config observability fields inherit the global value. `observability.logs.tags` remains specific to runner-config-owned log groups; shared singleton functions receive global `tags` and `lambda.tags`. The nullness of `observability.tracing.mode` must be known during planning because it selects X-Ray IAM statements and tracing blocks in runner-config consumers. -The global `experimental.compute_provider` block owns v2 defaults for EC2 settings such as VPC and subnet IDs, managed-security-group behavior, egress rules, additional security groups, CloudWatch agent configuration, instance-profile path, key name, public IPv4 association, and tags. It also owns the shared AMI housekeeper, instance-termination watcher, and runner-binary distribution. It does not fall back to corresponding flat module inputs. VPC and subnet values must therefore be supplied through the global or per-configuration EC2 block when needed. Global values should be set only when they are shared across every applicable runner configuration. The global block never selects a provider and does not contain provider-specific required runner-configuration fields. Every runner configuration must still populate exactly one typed provider block; that per-configuration block selects the provider, supplies required fields such as EC2 `instance_types`, and preserves support for mixed-provider maps. +The global `experimental.compute_provider` block owns v2 defaults for EC2 settings such as VPC and subnet IDs, managed-security-group behavior, egress rules, additional security groups, CloudWatch agent config, instance-profile path, key name, public IPv4 association, and tags. It also owns the shared AMI housekeeper, instance-termination watcher, and runner-binary distribution. It does not fall back to corresponding flat module inputs. VPC and subnet values must therefore be supplied through the global or per-runner-config EC2 block when needed. Global values should be set only when they are shared across every applicable runner config. The global block never selects a provider and does not contain provider-specific required runner-config fields. Every runner config must still populate exactly one typed provider block; that per-runner-config block selects the provider, supplies required fields such as EC2 `instance_types`, and preserves the per-runner selection point needed for future mixed-provider maps. -`experimental.compute_provider.ec2.runner_binaries` owns whether EC2 runner configurations use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. +`experimental.compute_provider.ec2.runner_binaries` owns whether EC2 runner configs use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable per-runner-config `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. -Webhook-orchestration runner-control artifacts are selected globally through `experimental.orchestration_provider.webhook.lambda.artifact.zip` or `experimental.orchestration_provider.webhook.lambda.artifact.s3.{key,object_version}` and shared by scale, pool, and job-retry. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The ingress webhook artifact remains separate under `experimental.orchestration_provider.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. +Webhook-orchestration runner-control artifacts are selected globally through `experimental.orchestration_provider.webhook.lambda.artifact.zip` or `experimental.orchestration_provider.webhook.lambda.artifact.s3.{key,object_version}` and shared by scale, pool, and job-retry. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `experimental.multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The ingress webhook artifact remains separate under `experimental.orchestration_provider.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `experimental.compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `experimental.compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. -Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-configuration tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes: shared SSM merges `experimental.tags` with `ssm.tags`, the webhook merges `experimental.lambda.tags` with `experimental.orchestration_provider.webhook.lambda.webhook.tags`, and the runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags` and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-configuration `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. +Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-config tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes. Shared SSM merges `experimental.tags`, `ssm.tags`, and the forced `ghr:environment` tag. The webhook base resources merge `experimental.tags` with that environment tag; its Lambda additionally merges `experimental.lambda.tags` with `experimental.orchestration_provider.webhook.lambda.webhook.tags`. The runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags`, the environment tag, and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-runner-config `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. -Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and runner-configuration log-group tags. Tracing stays under `observability.tracing`, and metrics enablement, namespace, and individual metric switches stay under `observability.metrics`. +Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and runner-config log-group tags. Tracing stays under `observability.tracing`, and metrics enablement, namespace, and individual metric switches stay under `observability.metrics`. -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`, demand-control resources under `orchestration_provider.webhook`, and provider-specific resources under `provider.`. The provider key is derived from the selected typed compute-provider block. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, scale-up resources at `runners_map_v2["configuration"].orchestration_provider.webhook.scale_up`, and launch-template and runner-log artifacts under `runners_map_v2["configuration"].provider.ec2`. The webhook `pool` value is null when no pool configuration is supplied. +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`, demand-control resources under `orchestration_provider.webhook`, and provider-specific resources under `provider.`. The provider key is derived from the selected typed compute-provider block. For example, a module-managed common runner role is available at `runners_map_v2[""].runner.role`; the value is null when the caller selects an external role. Scale-up resources are available at `runners_map_v2[""].orchestration_provider.webhook.scale_up`, and launch-template and runner-log artifacts are under `runners_map_v2[""].provider.ec2`. The top-level `scale_up`, `scale_down`, and `pool` fields remain compatibility aliases for their webhook-provider counterparts. The webhook `pool` value is null when no pool config is supplied. ## Plan-time provider selection and IAM shape -Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Provider ownership inputs continue to use caller-known wrapper objects as discriminators. The webhook orchestration leaves conditionally emit KMS statements from the nullable Parameter Store and build-queue key scalars; a null value omits the statement, while an apply-time-unknown ARN remains valid during planning. No placeholder or sentinel ARN is rendered. The relevant configuration fragments are: +Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Provider ownership inputs continue to use caller-known wrapper objects as discriminators. The webhook orchestration leaves conditionally emit KMS statements from the nullable Parameter Store and build-queue key scalars; a null value omits the statement, while an apply-time-unknown ARN remains valid during planning. These provider-owned statements do not render placeholder or sentinel ARNs. At the internal runner-config boundary, the relevant config fragments are: ```hcl ssm = { @@ -519,15 +521,17 @@ compute_provider = { } ``` -The populated `ec2` block tells both multi-runner routing and runner-config dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_configs` input forwards it unchanged at the runner-config boundary. The orchestration_provider wrapper follows the same exact-one rule. Within the compute block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. `ssm.kms_key_id`, `orchestration_provider.webhook.queue.kms_key_id`, and values such as `observability.logs.kms_key_id` remain nullable scalar inputs even when their ARNs are unknown until apply. +The populated `ec2` block tells both multi-runner routing and runner-config dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_configs` input forwards it unchanged at the runner-config boundary. The `orchestration_provider` wrapper follows the same exact-one rule. Within the compute block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. At this internal boundary, `ssm.kms_key_id`, the derived `orchestration_provider.webhook.queue.kms_key_id`, and values such as `observability.logs.kms_key_id` remain nullable scalar inputs even when their ARNs are unknown until apply. The public source of the derived queue key is `experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id`. -For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts the shared GitHub App parameters, configures the webhook with the same key, and adds matching decrypt permissions to every runner configuration so its control-plane functions can read those credentials. Its value may be unknown until apply. It does not select encryption for runtime-created runner parameters. Queue encryption is a separate global contract, may use a different CMK, and reaches only the scale-up consumer and job-retry publisher policies inside the webhook orchestration provider. +For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts shared GitHub App parameters created by the module, configures the webhook with the same key, and adds matching decrypt permissions to every runner config so its control-plane functions can read those credentials. Parameters selected through existing `*_ssm` references retain their external encryption and access requirements. The global key's value may be unknown until apply. It does not select encryption for runtime-created runner parameters. Queue encryption is a separate global contract, may use a different CMK, and reaches only the scale-up consumer and job-retry publisher policies inside the webhook orchestration provider. ## Migration phases -1. **Phase 1 — v2 opt-in and canonical translation (current):** A non-empty experimental map opts the whole module instance into v2, while an empty map preserves the existing `module.runners["configuration"]` addresses. Both stable and experimental inputs already resolve through the same canonical pipeline. The v2 switch is not an in-place state migration. +1. **Phase 1 — v2 opt-in and canonical translation (current):** A non-empty experimental map opts the whole module instance into v2, while an empty map preserves the existing `module.runners[""]` addresses. Both stable and experimental inputs already resolve through the same canonical pipeline. The v2 switch is not an in-place state migration. 2. **Phase 2 — deprecate legacy variables:** Deprecate the stable `multi_runner_config` and migrated flat inputs while retaining both dispatch paths and compatibility outputs for a release window. 3. **Phase 3 — remove legacy variables and migrate state:** In a breaking release, remove the deprecated inputs and flat output adapter, route the remaining canonical configuration through `runner-config`, and ship tested `moved` blocks plus commands for addresses Terraform cannot move declaratively. 4. **Phase 4 — remove `modules/runners`:** After direct consumers have had a separate deprecation and migration window, delete the legacy module. -A future compute provider must add a typed external input block, multi-runner normalization and routing, and an integration that returns the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Populating more than one external provider block, or selecting a block whose resources are not implemented, is intentionally rejected. +A future compute provider must add a typed external input block, multi-runner normalization and routing, a provider-specific `trust-policy` submodule, runner-config dispatch, and an integration that returns the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Today the typed schema exposes only `ec2`, so unsupported provider attributes fail input-schema validation. When another implemented block is added, the exact-one selection preconditions will reject runner configs that populate more than one supported compute provider. + +A future orchestration provider must add a typed global-default block where shared settings are needed, a typed per-runner-config selector block, runner-config dispatch, a capability adapter for each supported compute provider, provider-grouped outputs, and focused routing and coexistence tests. Once a second typed orchestration provider exists, validation must also reject a runner config that selects more than one provider. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 297c2555f8..493ff7fc1b 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -21,7 +21,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/modules/internal/compute-provider-refactor/) for the motivation, ownership contract, opt-in flow, state guarantees, and migration phases. +See [Experimental orchestration- and 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. From 6e9433f56f40b2212a041f016442cfc4a72f4bc5 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Mon, 17 Aug 2026 16:21:32 +0200 Subject: [PATCH 33/49] feat(compute-providers): namespace AWS EC2 provider --- .github/workflows/terraform.yml | 8 +- ...-runner-orchestration-provider-boundary.md | 22 +- docs/index.md | 6 +- .../internal/compute-provider-refactor.md | 192 ++-- modules/compute-providers/aws/ec2/README.md | 80 ++ .../{ => aws}/ec2/control-plane.tf | 0 .../{ => aws}/ec2/instance-profile.tf | 0 .../{ => aws}/ec2/logging.tf | 0 .../{ => aws}/ec2/outputs.tf | 0 .../{ => aws}/ec2/policies-runner.tf | 0 .../{ => aws}/ec2/provider-contract.tf | 0 .../{ => aws}/ec2/runner-config.tf | 0 .../{ => aws}/ec2/runner-instances.tf | 0 .../ec2/templates/cloudwatch_config.json | 0 .../ec2/templates/install-runner-osx.sh | 0 .../ec2/templates/install-runner.ps1 | 0 .../{ => aws}/ec2/templates/install-runner.sh | 0 .../ec2/templates/start-runner-osx.sh | 0 .../{ => aws}/ec2/templates/start-runner.ps1 | 0 .../{ => aws}/ec2/templates/start-runner.sh | 0 .../{ => aws}/ec2/templates/user-data-osx.sh | 0 .../{ => aws}/ec2/templates/user-data.ps1 | 0 .../{ => aws}/ec2/templates/user-data.sh | 0 .../{ => aws}/ec2/tests/provider.tftest.hcl | 0 .../{ => aws}/ec2/trust-policy/README.md | 10 +- .../{ => aws}/ec2/trust-policy/assume-role.tf | 0 .../{ => aws}/ec2/trust-policy/outputs.tf | 0 .../tests/trust-policy.tftest.hcl | 0 .../{ => aws}/ec2/trust-policy/validations.tf | 0 .../{ => aws}/ec2/trust-policy/variables.tf | 0 .../{ => aws}/ec2/trust-policy/versions.tf | 0 .../{ => aws}/ec2/validations.tf | 12 +- .../{ => aws}/ec2/variables.tf | 2 +- .../{ => aws}/ec2/versions.tf | 0 modules/compute-providers/ec2/README.md | 80 -- modules/multi-runner/README.md | 52 +- modules/multi-runner/ami-housekeeper.tf | 18 +- modules/multi-runner/compute-provider.tf | 33 +- .../config.experimental.translation.tf | 312 +++--- modules/multi-runner/main.tf | 4 +- modules/multi-runner/outputs.tf | 6 +- modules/multi-runner/runner-binaries.tf | 32 +- modules/multi-runner/runners.tf | 92 +- modules/multi-runner/termination-watcher.tf | 22 +- .../fixtures/computed-runner-inputs/main.tf | 26 +- .../tests/provider-routing-v1.tftest.hcl | 77 +- .../tests/provider-routing-v2.tftest.hcl | 915 ++++++++++-------- .../multi-runner/validations.experimental.tf | 85 +- .../multi-runner/variables.experimental.tf | 756 ++++++++------- modules/runner-config/README.md | 26 +- .../{ec2.tf => compute-provider.aws.ec2.tf} | 24 +- modules/runner-config/compute-provider.tf | 22 +- modules/runner-config/outputs.tf | 6 +- .../computed-iam-inputs.tf | 58 +- modules/runner-config/tests/pool.tftest.hcl | 93 +- modules/runner-config/tests/tags.tftest.hcl | 34 +- modules/runner-config/validations.tf | 4 +- .../variables.compute-provider.tf | 465 ++++----- 58 files changed, 1909 insertions(+), 1665 deletions(-) create mode 100644 modules/compute-providers/aws/ec2/README.md rename modules/compute-providers/{ => aws}/ec2/control-plane.tf (100%) rename modules/compute-providers/{ => aws}/ec2/instance-profile.tf (100%) rename modules/compute-providers/{ => aws}/ec2/logging.tf (100%) rename modules/compute-providers/{ => aws}/ec2/outputs.tf (100%) rename modules/compute-providers/{ => aws}/ec2/policies-runner.tf (100%) rename modules/compute-providers/{ => aws}/ec2/provider-contract.tf (100%) rename modules/compute-providers/{ => aws}/ec2/runner-config.tf (100%) rename modules/compute-providers/{ => aws}/ec2/runner-instances.tf (100%) rename modules/compute-providers/{ => aws}/ec2/templates/cloudwatch_config.json (100%) rename modules/compute-providers/{ => aws}/ec2/templates/install-runner-osx.sh (100%) rename modules/compute-providers/{ => aws}/ec2/templates/install-runner.ps1 (100%) rename modules/compute-providers/{ => aws}/ec2/templates/install-runner.sh (100%) rename modules/compute-providers/{ => aws}/ec2/templates/start-runner-osx.sh (100%) rename modules/compute-providers/{ => aws}/ec2/templates/start-runner.ps1 (100%) rename modules/compute-providers/{ => aws}/ec2/templates/start-runner.sh (100%) rename modules/compute-providers/{ => aws}/ec2/templates/user-data-osx.sh (100%) rename modules/compute-providers/{ => aws}/ec2/templates/user-data.ps1 (100%) rename modules/compute-providers/{ => aws}/ec2/templates/user-data.sh (100%) rename modules/compute-providers/{ => aws}/ec2/tests/provider.tftest.hcl (100%) rename modules/compute-providers/{ => aws}/ec2/trust-policy/README.md (93%) rename modules/compute-providers/{ => aws}/ec2/trust-policy/assume-role.tf (100%) rename modules/compute-providers/{ => aws}/ec2/trust-policy/outputs.tf (100%) rename modules/compute-providers/{ => aws}/ec2/trust-policy/tests/trust-policy.tftest.hcl (100%) rename modules/compute-providers/{ => aws}/ec2/trust-policy/validations.tf (100%) rename modules/compute-providers/{ => aws}/ec2/trust-policy/variables.tf (100%) rename modules/compute-providers/{ => aws}/ec2/trust-policy/versions.tf (100%) rename modules/compute-providers/{ => aws}/ec2/validations.tf (71%) rename modules/compute-providers/{ => aws}/ec2/variables.tf (99%) rename modules/compute-providers/{ => aws}/ec2/versions.tf (100%) delete mode 100644 modules/compute-providers/ec2/README.md rename modules/runner-config/{ec2.tf => compute-provider.aws.ec2.tf} (50%) diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index ae858db134..242a0c4412 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -85,8 +85,8 @@ jobs: "download-lambda", "lambda", "multi-runner", - "compute-providers/ec2", - "compute-providers/ec2/trust-policy", + "compute-providers/aws/ec2", + "compute-providers/aws/ec2/trust-policy", "runner-binaries-syncer", "orchestration-providers/webhook", "orchestration-providers/webhook/job-retry", @@ -229,8 +229,8 @@ jobs: - modules/orchestration-providers/webhook/scale-runners - modules/runner-config - modules/runner-config/ssm-housekeeper - - modules/compute-providers/ec2 - - modules/compute-providers/ec2/trust-policy + - modules/compute-providers/aws/ec2 + - modules/compute-providers/aws/ec2/trust-policy defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index 0dc43b7c61..3583f389e3 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -65,8 +65,10 @@ experimental = { } compute_provider = { - ec2 = { - instance_types = ["m7g.large"] + aws = { + ec2 = { + instance_types = ["m7g.large"] + } } } } @@ -126,8 +128,8 @@ The Terraform implementation is split as follows: | `modules/orchestration-providers/webhook/pool` | Owns optional scheduled pool resources and IAM. | | `modules/orchestration-providers/webhook/job-retry` | Owns optional queued-job retry resources and IAM. | | `modules/runner-config/ssm-housekeeper` | Owns provider-neutral cleanup of runner token and configuration parameters, including its component-specific Lambda artifact. | -| `modules/compute-providers//trust-policy` | Produces the provider-specific runner-role trust policy before the common runner role is resolved. | -| `modules/compute-providers/` | Owns capacity resources and returns policy, environment-variable, managed-policy, and resource capabilities. | +| `modules/compute-providers///trust-policy` | Produces the provider-specific runner-role trust policy before the common runner role is resolved. | +| `modules/compute-providers//` | Owns capacity resources and returns policy, environment-variable, managed-policy, and resource capabilities. | The former `modules/runner-stack` name becomes `modules/runner-config`. “Runner config” describes the module's purpose without implying a specific deployment topology. @@ -138,9 +140,9 @@ flowchart TD Selector --> Webhook["orchestration-providers/webhook"] Selector -. future .-> ScaleSet["orchestration-providers/scale-set"] Config --> ComputeSelector{"exactly one compute provider"} - ComputeSelector --> Trust["compute-providers/ec2/trust-policy"] + ComputeSelector --> Trust["compute-providers/aws/ec2/trust-policy"] Trust --> Role["runner role"] - Role --> EC2["compute-providers/ec2"] + Role --> EC2["compute-providers/aws/ec2"] EC2 --> Capabilities["compute capabilities"] Capabilities --> Adapter["runner-config capability adapter"] Adapter --> Webhook @@ -170,11 +172,11 @@ A future scale-set controller may require a different subset or extension of the Stable inputs are translated into the same internal canonical representation so defaults and shared singleton values have one resolution path. Stable runner configs continue to call the existing `modules/runners` implementation at their existing addresses. Opting into experimental v2 is module-wide: a non-empty `experimental.multi_runner_config` replaces, rather than merges with, the stable map. -The runner config uses one explicitly named, count-addressed module per concrete provider. Compute modules follow `module.compute_[0]`, while orchestration modules follow `module.orchestration_[0]`. +The runner config uses one explicitly named, count-addressed module per concrete provider. Compute modules follow `module.compute__[0]`, while orchestration modules follow `module.orchestration_[0]`. The AWS EC2 modules therefore use `module.compute_aws_ec2_trust_policy[0]` and `module.compute_aws_ec2[0]`. -The experimental v2 implementation does not retain declarative moves from earlier unpublished module names. Those addresses are not part of the stable contract. An existing experimental deployment must migrate any affected state explicitly before upgrading or accept Terraform's proposed replacement actions. +Declarative moved blocks preserve existing experimental state for the AWS namespace rename: `module.compute_ec2_trust_policy[0]` moves to `module.compute_aws_ec2_trust_policy[0]`, and `module.compute_ec2[0]` moves to `module.compute_aws_ec2[0]`. These moves are scoped to those v2 child modules; unrelated earlier experimental addresses remain outside the stable contract and require explicit migration when affected. -The canonical v2 output groups resources under `orchestration_provider.webhook`. Direct `scale_up`, `scale_down`, and `pool` outputs remain compatibility aliases during the experimental transition. +The canonical v2 output groups demand-control resources under `orchestration_provider.webhook` and compute resources under the selected namespace and provider, currently `provider.aws.ec2`. Direct `scale_up`, `scale_down`, and `pool` outputs remain compatibility aliases during the experimental transition. Moved blocks preserve resource state addresses but cannot rewrite configuration expressions, so consumers must update references from the former experimental `provider.ec2` path. This ADR does not define an automatic stable-v1-to-v2 state migration. Existing deployments remain on the stable path until that migration is separately designed and documented. @@ -273,7 +275,7 @@ Implementation and review must verify the boundary at several levels. - Per-runner values override global webhook defaults, and omitted nullable values inherit them. - Shared singleton resources consume global values rather than arbitrary per-runner overrides. - Stable inputs preserve stable resource addresses and output shape. -- Canonical runner-config, compute-provider, and orchestration-provider addresses are used consistently in fresh plans. +- Fresh plans use the canonical `module.compute_aws_ec2_trust_policy[0]` and `module.compute_aws_ec2[0]` runner-config addresses, with declarative moved blocks mapping the prior v2 child labels. - Canonical nested outputs and compatibility aliases reference the same resources. ### Provider integration tests diff --git a/docs/index.md b/docs/index.md index d16bb7a3cb..cdfe9ed130 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,13 +111,13 @@ Global `experimental.orchestration_provider.webhook.queue` owns v2 defaults for V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to an empty list. These nested values are authoritative end-to-end: the shared Parameter Store module persists or selects their credentials, and v2 runner configurations consume the resulting references. Flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration_provider.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures v2 runner-config GitHub clients and the termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. Both client settings belong inside `experimental.github`; they are not root `experimental` fields or per-configuration orchestration settings. -The shared webhook, runner configurations, SSM housekeepers, runner-binary syncer, termination watcher, and AMI housekeeper consume the provider-neutral runtime, architecture, networking, role, and tag defaults under `experimental.lambda`; `lambda.principals` configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global observability settings configure logging and tracing, and global metrics also configure the termination watcher. The runner-control artifact shared by webhook scale, pool, and job-retry is selected globally under `experimental.orchestration_provider.webhook.lambda.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. `experimental.orchestration_provider.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `experimental.orchestration_provider.webhook.lambda.webhook` owns the separate ingress webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. +The shared webhook, runner configurations, SSM housekeepers, runner-binary syncer, termination watcher, and AMI housekeeper consume the provider-neutral runtime, architecture, networking, role, and tag defaults under `experimental.lambda`; `lambda.principals` configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global observability settings configure logging and tracing, and global metrics also configure the termination watcher. The runner-control artifact shared by webhook scale, pool, and job-retry is selected globally under `experimental.orchestration_provider.webhook.lambda.artifact`: `artifact.zip` selects a local archive, while `artifact.s3.{key,object_version}` selects an object from the shared `experimental.lambda.artifact.s3.bucket`. Leaving both artifact sources null uses the packaged runner archive. The module validates that zip and S3 are not selected together and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation preserves the legacy precedence in which a configured flat S3 bucket wins over the flat runner zip. The shared bucket alone selects no component. Each artifact-capable singleton—including the webhook—uses it only when that component's separate nested `artifact.s3` wrapper supplies its key and optional object version. Runner-config's common SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. The runner-binary syncer follows the same parallel selector at `experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.{zip,s3}`, with its S3 key and optional object version also resolved against `experimental.lambda.artifact.s3.bucket`. `experimental.orchestration_provider.webhook` owns queue selection, EventBridge routing, accepted event types, and matcher-parameter tier, while `experimental.orchestration_provider.webhook.lambda.webhook` owns the separate ingress webhook artifact, API Gateway access logs, sizing, and component tags. `compute_provider.aws.ec2.instance_termination_watcher`, `compute_provider.aws.ec2.ami.housekeeper`, and `compute_provider.aws.ec2.runner_binaries` own their singleton-specific features, artifacts, sizing, schedules, and related settings. -`experimental.compute_provider.ec2.runner_binaries.enabled` defaults each EC2 runner configuration to the shared synchronized distribution, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. +`experimental.compute_provider.aws.ec2.runner_binaries.enabled` defaults each EC2 runner configuration to the shared synchronized distribution, while a nullable per-configuration `compute_provider.aws.ec2.binaries_syncer.enabled` can override it. Enabled distributions are created once per unique operating-system and architecture pair. The global enable value, distribution encryption enablement, distribution KMS-key nullness, and access-logging bucket nullness must be known during planning because they determine module or resource shape. A distribution-bucket CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from that setting; attach decrypt permission to module-managed or external runner roles separately. Global `ssm.paths.root` is the base for shared and runner-configuration-owned parameters. The shared GitHub App and webhook paths append `ssm.paths.app` (default `app`) and `ssm.paths.webhook` (default `webhook`), while normalization appends the runner-configuration key only for configuration-owned paths. The default derived base is `/github-action-runners/${prefix}`, and runner token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional ARN-valued scalar that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration; it does not select encryption for runtime-created runner parameters. Webhook-provider leaves conditionally omit their KMS statements when this value is null, while apply-time-unknown key ARNs remain valid during planning. Nested metrics retain the established defaults: disabled, using the `GitHub Runners` namespace, with the rate-limit, job-retry, Spot-termination, and Spot-warning switches enabled. Spot metrics are global termination-watcher settings rather than per-configuration overrides. -Each runner configuration selects two independent typed providers: one `orchestration_provider` provider for demand control and one `compute_provider` for runner capacity. `orchestration_provider.webhook` is the sole supported orchestration provider today. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. The wrapped provider objects reach runner-config, which validates each exact-one selection and dispatches the matching root-level provider module. `orchestration-providers/webhook` owns scale-up, scale-down, scheduled pool, job retry, and their webhook-specific defaults and tag layering; runner-config retains common SSM housekeeping plus 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 modules are internal experimental implementation boundaries, not standalone public entry points. Later releases can add mutually exclusive orchestration siblings without changing the common or compute-provider contracts. See the [experimental orchestration- and 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. +Each runner configuration selects two independent typed providers: one `orchestration_provider` provider for demand control and one namespaced `compute_provider` for runner capacity. `orchestration_provider.webhook` is the sole supported orchestration provider today. The global `experimental.compute_provider` block owns shared v2 provider defaults plus the runner-binary, termination-watcher, and AMI-housekeeper singleton configuration, but it does not select a provider. Today the only selectable compute leaf is `compute_provider.aws.ec2`. The wrapped provider objects reach runner-config, which validates each exact-one selection and dispatches the matching root-level provider module. `orchestration-providers/webhook` owns scale-up, scale-down, scheduled pool, job retry, and their webhook-specific defaults and tag layering; runner-config retains common SSM housekeeping plus the common runner role and attachments. The EC2 implementation lives under `compute-providers/aws/ec2`, supplies EC2-specific policy requirements, and owns the instance profile, launch template, bootstrap resources, and runner log groups. Runner-config dispatches it at `module.compute_aws_ec2[0]` and exposes its resources under the matching nested output path `provider.aws.ec2` (for multi-runner, `runners_map_v2[""].provider.aws.ec2`). Declarative moved blocks preserve state created at the earlier experimental `module.compute_ec2[0]` and `module.compute_ec2_trust_policy[0]` child addresses when upgrading to the namespaced labels. They do not migrate stable-v1 `module.runners` state to v2, and they cannot rewrite configuration references from `provider.ec2` to `provider.aws.ec2`. These modules are internal experimental implementation boundaries, not standalone public entry points. Later releases can add mutually exclusive namespace and provider siblings without changing the common contract. See the [experimental orchestration- and 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 533a5c4bc2..4577ffc5ba 100644 --- a/docs/modules/internal/compute-provider-refactor.md +++ b/docs/modules/internal/compute-provider-refactor.md @@ -23,8 +23,8 @@ The implementation is split into common runner-config composition, orchestration | `orchestration-providers/webhook/pool` | Optional scheduled runner-pool resources and their Lambda and IAM wiring. | | `orchestration-providers/webhook/job-retry` | Optional queued-job retry resources and their Lambda and IAM wiring. | | `runner-config/ssm-housekeeper` | Parameter Store cleanup Lambda, schedule, logging, and IAM resources. | -| `compute-providers//trust-policy` | Provider-specific default runner-role trust, merged with the optional caller-provided trust document before the common role is created. | -| `compute-providers/` | Provider-specific resources, permission requirements, and the IAM and environment-variable fragments consumed by the common control plane after the runner role is resolved. | +| `compute-providers///trust-policy` | Provider-specific default runner-role trust, merged with the optional caller-provided trust document before the common role is created. | +| `compute-providers//` | Provider-specific resources, permission requirements, and the IAM and environment-variable fragments consumed by the common control plane after the runner role is resolved. | The EC2 provider owns the instance profile, launch template, security group, AMI and EC2-specific bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. EC2 is the only implemented Terraform compute provider in this phase. @@ -32,11 +32,11 @@ Runner-config, the root orchestration and compute providers, and their leaf modu Each external v2 runner config selects demand orchestration separately from its compute provider. The required `orchestration_provider` wrapper has one supported provider today: `experimental.multi_runner_config..orchestration_provider.webhook`. It owns the runner config's lifecycle and maximum runner count, registration scope, matcher, build-queue overrides, scale-up, scale-down, pool, and job-retry settings. The wrapper is intentionally typed as a provider boundary so later orchestration implementations can be added as mutually exclusive siblings without moving common settings again. -The runner config also populates exactly one typed compute-provider block, such as `experimental.multi_runner_config..compute_provider.ec2`; that block's presence must be known during planning because it determines capacity routing. Multi-runner resource preconditions enforce both selections and the public contract's cross-scope and plan-shaping rules, while each provider implementation validates its resolved internal contract. +The runner config also populates exactly one typed compute-provider leaf, such as `experimental.multi_runner_config..compute_provider.aws.ec2`; that leaf's presence must be known during planning because it determines capacity routing. Multi-runner resource preconditions enforce both selections and the public contract's cross-scope and plan-shaping rules, while each provider implementation validates its resolved internal contract. -After resolving global `experimental.compute_provider.ec2` values with the selected runner config's `compute_provider.ec2` overrides, `multi-runner` preserves the typed wrapper expected by `runner-config`. The direct contract is `compute_provider = { ec2 = { ... } }`, not a flat EC2 object. Runner-config validates that exactly one compute-provider block is non-null, derives the provider type from that block, and passes `compute_provider.` to the selected provider module as its nested `config` object. It independently validates the exact-one `orchestration_provider = { webhook = { ... } }` wrapper and invokes the selected root orchestration provider with provider-neutral common objects such as `runner`, the Lambda substrate, SSM, observability, and the selected compute-provider capabilities. +After resolving global `experimental.compute_provider.aws.ec2` values with the selected runner config's `compute_provider.aws.ec2` overrides, `multi-runner` preserves the namespaced typed wrapper expected by `runner-config`. The direct contract is `compute_provider = { aws = { ec2 = { ... } } }`, not a flat EC2 object. Runner-config flattens each populated namespace and provider leaf into an internal dispatch key such as `aws_ec2`, validates that exactly one leaf is non-null, and passes `compute_provider.aws.ec2` to `module.compute_aws_ec2[0]` as its nested `config` object. The webhook runtime registry still receives the provider type `ec2`; the namespace is part of Terraform dispatch so different clouds can expose similarly named services without colliding. Runner-config independently validates the exact-one `orchestration_provider = { webhook = { ... } }` wrapper and invokes the selected root orchestration provider with provider-neutral common objects such as `runner`, the Lambda substrate, SSM, observability, and the selected compute-provider capabilities. -Binary discovery is completed before the runner-config call. `config.experimental.translation.tf` enriches the final canonical runner config at `compute_provider.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_configs` call then passes that runner config's wrapped `compute_provider` object unchanged. Runner-config and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.ec2.binaries_syncer`. +Binary discovery is completed before the runner-config call. `config.experimental.translation.tf` enriches the final canonical runner config at `compute_provider.aws.ec2.binaries_syncer.s3`, leaving `s3` null when synchronization is disabled. The `module.runner_configs` call then passes that runner config's wrapped `compute_provider` object unchanged. Runner-config and the EC2 provider therefore receive the typed provider-owned shape; neither expects a bare `{ arn, id, key }` object directly at `compute_provider.aws.ec2.binaries_syncer`. Runner-config creates or selects the runner IAM role, but the current EC2 provider owns the role's default trust-policy document. Each provider implementation supplies a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. When runner-config creates the runner role, it uses the isolated trust-policy output and attaches the returned runner policies. An external role bypasses both operations, so its caller owns trust and permissions. Runner-config passes the scale-up, scale-down, and pool capabilities to the selected orchestration provider in either case. A provider never creates or attaches the common runner IAM role. @@ -44,7 +44,7 @@ The trust relationship is deliberately rendered by an isolated provider submodul 1. `multi-runner` validates the runner config's typed orchestration- and compute-provider selections, resolves its global and per-runner-config values, and invokes `runner-config` with both wrapped provider configs. 2. `runner-config` independently derives the orchestration and compute providers from their single non-null typed blocks. -3. `compute-providers//trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input. +3. `compute-providers///trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input. 4. `runner-config` creates the common runner role from the returned `assume_role_policy`, or selects an external role without applying that trust policy. 5. The full compute provider receives the resolved role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements. 6. The provider returns its nested policy, environment-variable, and resource contract. @@ -58,7 +58,7 @@ Multi-runner produces one canonical consumer representation for both input modes 1. `config.experimental.translation.tf` selects the module mode and builds `local.raw_translated_experimental`. A non-empty experimental runner-config map selects the nested `var.experimental` input for v2; otherwise the file projects flat module globals and stable `multi_runner_config` entries into the same schema for v1. 2. The same translation file then derives `local.translated_experimental_base`. It applies schema defaults and global/runner-config precedence, merges tags, resolves IAM ownership and paths, and normalizes observability, `orchestration_provider.webhook`, and compute-provider values. Provider selection, plan-shaping validation, and the shared runner-binary syncer and discovery consume this fully resolved base. -3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, shared Lambda artifacts and principals, the internal build-queue KMS projection and runner-control artifact, SSM KMS, and each enabled EC2 runner config's `compute_provider.ec2.binaries_syncer.s3`. Webhook event-source mapping and pool resolution are already complete in the base object. The remaining shared components, webhook queues, and runner implementations consume the final canonical object. +3. After runner-binary discovery, the translation file derives the final `local.translated_experimental`. It completes runner labels, GitHub enterprise and User-Agent settings, shared Lambda artifacts and principals, the internal build-queue KMS projection and runner-control artifact, SSM KMS, and each enabled EC2 runner config's `compute_provider.aws.ec2.binaries_syncer.s3`. Webhook event-source mapping and pool resolution are already complete in the base object. The remaining shared components, webhook queues, and runner implementations consume the final canonical object. Stable translation always emits `orchestration_provider.webhook`, but stable runner configs remain on `module.runners[""]`: `runners.tf` adapts each final canonical runner config back to the existing `modules/runners` input contract, preserving Terraform addresses without maintaining a separate config source. This is not the phase-2 implementation migration to `runner-config`. For v2, `module.runner_configs` directly iterates the gated final runner-config map. Its adapter passes environment-augmented tags, GitHub settings with live App references, and the resolved Lambda, SSM, and observability inputs at the runner-config top level. It injects the live build queue into the webhook orchestration input, forwards the provider-owned fields accepted by runner-config, and omits `matcherConfig` because the shared webhook consumes it. The wrapped compute-provider object is forwarded unchanged. Binary output enrichment and all other derived config shaping are already complete in canonical translation. @@ -75,7 +75,7 @@ flowchart TD V1 --> Base["translated_experimental_base: defaults and global/runner-config resolution"] V2 --> Base Base --> Discovery["Provider selection, runner-binary syncer, and discovery"] - Discovery --> Final["translated_experimental: enrich EC2 binaries_syncer.s3"] + Discovery --> Final["translated_experimental: enrich aws.ec2 binaries_syncer.s3"] Final --> Singleton["Shared SSM, webhook, termination watcher, and AMI housekeeper"] Final --> Shared["Webhook build queues and matching"] Final -->|v1 legacy-argument adapter| Legacy["module.runners[key]"] @@ -85,10 +85,10 @@ flowchart TD Orchestration --> Pool["orchestration-providers/webhook/pool"] Orchestration --> Retry["orchestration-providers/webhook/job-retry"] RunnerConfig --> Housekeeper["runner-config/ssm-housekeeper"] - RunnerConfig --> Trust["compute-providers//trust-policy"] + RunnerConfig --> Trust["compute-providers///trust-policy"] Trust --> Role["common runner role"] Role --> Provider - RunnerConfig --> Provider["compute-providers/"] + RunnerConfig --> Provider["compute-providers//"] Provider --> Contract["compute-provider capability contract"] Contract --> Adapter["runner-config capability adapter"] Adapter --> Orchestration @@ -102,11 +102,11 @@ The canonical object gives shared singleton resources one global representation - The v1 translation wraps its existing registration scope, matcher, queue, scale, pool, and retry values under `orchestration_provider.webhook`; the stable public input and resource behavior remain unchanged. - Stable queue tagging and the flat `runners_map` output remain unchanged. - When `experimental.multi_runner_config` is non-empty, every key in the experimental map calls `modules/runner-config` at `module.runner_configs[""]`; stable-map entries are not dispatched. -- Experimental v2 uses `module.runner_configs[""]`; within each entry, the canonical provider child addresses are `module.runner_configs[""].module.compute_ec2_trust_policy[0]`, `module.runner_configs[""].module.compute_ec2[0]`, and `module.runner_configs[""].module.orchestration_webhook[0]`. Earlier experimental addresses are not migrated automatically. +- Experimental v2 uses `module.runner_configs[""]`; within each entry, the canonical provider child addresses are `module.runner_configs[""].module.compute_aws_ec2_trust_policy[0]`, `module.runner_configs[""].module.compute_aws_ec2[0]`, and `module.runner_configs[""].module.orchestration_webhook[0]`. Moved blocks inside `runner-config` preserve existing experimental state from the earlier `module.compute_ec2_trust_policy[0]` and `module.compute_ec2[0]` child labels when upgrading to these namespaced labels. - Experimental resources are exposed separately through the nested `runners_map_v2` output. - The maps are not combined. A non-empty v2 map has explicit priority over the stable map. -No v1-to-v2 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. +The provider-label moves are limited to the existing v2 child modules. No v1-to-v2 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. Moved blocks also cannot update Terraform expression references, so consumers must change the experimental output path from `provider.ec2` to `provider.aws.ec2`. 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 @@ -312,84 +312,86 @@ module "multi_runner" { # required provider fields. Runner-binary settings are global because each # syncer is shared by runner configs with the same OS and architecture. compute_provider = { - ec2 = { - vpc_id = var.vpc_id - subnet_ids = var.subnet_ids + aws = { + ec2 = { + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + + ami = { + housekeeper = { + enabled = true + cleanup_config = { + minimumDaysOld = 30 + dryRun = true + } + artifact = { + zip = null + s3 = { + key = "ami-housekeeper.zip" + object_version = null + } + } + lambda = { + memory_size = 256 + timeout = 300 + } + schedule = { + expression = "cron(11 7 * * ? *)" + } + } + } - ami = { - housekeeper = { + instance_termination_watcher = { enabled = true - cleanup_config = { - minimumDaysOld = 30 - dryRun = true + features = { + enable_spot_termination_handler = true + enable_spot_termination_notification_watcher = true } + enable_runner_deregistration = true + environment_variables = {} artifact = { zip = null s3 = { - key = "ami-housekeeper.zip" + key = "termination-watcher.zip" object_version = null } } lambda = { - memory_size = 256 - timeout = 300 - } - schedule = { - expression = "cron(11 7 * * ? *)" + memory_size = 512 + timeout = 30 } } - } - instance_termination_watcher = { - enabled = true - features = { - enable_spot_termination_handler = true - enable_spot_termination_notification_watcher = true - } - enable_runner_deregistration = true - environment_variables = {} - artifact = { - zip = null + runner_binaries = { + enabled = true s3 = { - key = "termination-watcher.zip" - object_version = null - } - } - lambda = { - memory_size = 512 - timeout = 30 - } - } - - runner_binaries = { - enabled = true - s3 = { - encryption = { - enabled = true - bucket_key_enabled = null - sse_algorithm = "AES256" - kms_master_key_id = null - } - tags = {} - versioning = "Disabled" - logging = { - bucket = null - prefix = null - } - } - syncer = { - # Both null selects the packaged syncer archive. Set at most one. - artifact = { - zip = null - s3 = null - } - lambda = { - memory_size = 256 - timeout = 300 + encryption = { + enabled = true + bucket_key_enabled = null + sse_algorithm = "AES256" + kms_master_key_id = null + } + tags = {} + versioning = "Disabled" + logging = { + bucket = null + prefix = null + } } - schedule = { - expression = "cron(27 * * * ? *)" - state = "ENABLED" + syncer = { + # Both null selects the packaged syncer archive. Set at most one. + artifact = { + zip = null + s3 = null + } + lambda = { + memory_size = 256 + timeout = 300 + } + schedule = { + expression = "cron(27 * * * ? *)" + state = "ENABLED" + } } } } @@ -456,8 +458,10 @@ module "multi_runner" { # Each runner config also selects exactly one compute provider and supplies its # provider-specific values here. compute_provider = { - ec2 = { - instance_types = ["m7g.large"] + aws = { + ec2 = { + instance_types = ["m7g.large"] + } } } } @@ -468,9 +472,9 @@ module "multi_runner" { ## Inputs, tags, and outputs -The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-config map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configs and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-runner-config SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration_provider.webhook`: runner lifecycle, boot time, and maximum count; repository filtering; shared routing and matcher storage; queue defaults and encryption; the runner-control artifact shared by scale, pool, and job-retry; and the ingress webhook, scale, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner config's separate `orchestration_provider` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. +The `experimental` object has global siblings for `tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider`, in addition to its runner-config map at `multi_runner_config`. Root `experimental.lambda` contains only provider-neutral shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, role, and tag values. These settings configure v2 runner configs and shared consumers beyond webhook orchestration, including the runner-binary syncer, termination watcher, AMI housekeeper, and per-runner-config SSM housekeepers. `lambda.principals` configures v2 runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the shared webhook role. Global webhook-specific defaults are grouped under `experimental.orchestration_provider.webhook`: runner lifecycle, boot time, and maximum count; repository filtering; shared routing and matcher storage; queue defaults and encryption; the runner-control artifact shared by scale, pool, and job-retry; and the ingress webhook, scale, and pool Lambda component settings. The global orchestration block is a defaults namespace, while each runner config's separate `orchestration_provider` wrapper is the exact-one provider selector. The termination watcher, AMI housekeeper, and runner-binary syncer retain their nested component owners under `compute_provider.aws.ec2`. The active flat-only settings are `prefix`, `aws_partition`, and `aws_region`; legacy `iam_overrides` remains in the schema without an active consumer. -Each v2 runner config groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider.`. Demand-control settings live under a separate `orchestration_provider` wrapper. Its sole supported block today is `orchestration_provider.webhook`, containing provider-owned runner lifecycle, boot-time, and capacity settings, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale.up`, `lambda.scale.down`, `lambda.pool`, and `job_retry`. A nullable per-runner-config field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner config is therefore a non-null runner-config override followed by the global nested value, including that field's nested schema default. Per-runner-config precedence does not replace singleton-owned global settings: the shared GitHub App Parameter Store module, webhook implementation and routing, runner-binary syncer settings, termination watcher, and AMI housekeeper use translated globals. The shared webhook nevertheless aggregates each webhook runner config's matcher, build queue, and compute-provider route. A runner config's resolved binary-syncer enablement and OS/architecture determine whether its pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. +Each v2 runner config groups common provider-neutral settings by owner under `runner`, `lambda`, `ssm`, and `observability`; backend settings live under `compute_provider..`. Demand-control settings live under a separate `orchestration_provider` wrapper. Its sole supported block today is `orchestration_provider.webhook`, containing provider-owned runner lifecycle, boot-time, and capacity settings, `github.organization_runners`, `matcherConfig`, `queue`, `lambda.scale.up`, `lambda.scale.down`, `lambda.pool`, and `job_retry`. A nullable per-runner-config field inherits its corresponding experimental global when omitted or null, except that an external runner role suppresses inherited IAM management inputs. Precedence within a runner config is therefore a non-null runner-config override followed by the global nested value, including that field's nested schema default. Per-runner-config precedence does not replace singleton-owned global settings: the shared GitHub App Parameter Store module, webhook implementation and routing, runner-binary syncer settings, termination watcher, and AMI housekeeper use translated globals. The shared webhook nevertheless aggregates each webhook runner config's matcher, build queue, and compute-provider route. A runner config's resolved binary-syncer enablement and OS/architecture determine whether its pair participates in the shared syncer set; all syncer and distribution-bucket settings remain global. Global `experimental.orchestration_provider.webhook.queue` owns v2 build-queue defaults. `delay_webhook_event` defaults to `30`, `job_queue_retention_in_seconds` to `86400`, `visibility_timeout_seconds` to `180`, and `tags` to `{}`. `redrive_build_queue.enabled` defaults to `false`, while `redrive_build_queue.maxReceiveCount` defaults to null. A null per-runner-config redrive wrapper or leaf inherits its corresponding global value, and an enabled result requires a resolved `maxReceiveCount` greater than zero. Fields under `experimental.multi_runner_config[].orchestration_provider.webhook.queue` override those global defaults, and runner-config queue tags merge over global queue tags. Build-queue visibility is independent from Lambda config: `experimental.multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout` controls the function only, while `experimental.multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds` controls SQS and must be at least six times the resolved scale-up timeout. @@ -486,17 +490,17 @@ Global `ssm.paths.root` is the base for shared GitHub App and webhook parameters Global `observability` values provide defaults for every runner config and configure the applicable shared singleton consumers. Log level, retention, KMS key, class, and tracing configure the webhook, runner-binary syncer, termination watcher, and AMI housekeeper; metrics also configure the termination watcher. The nested defaults preserve established behavior: logs use level `info`, 180-day retention, no customer-managed KMS key, and class `STANDARD`; tracing defaults to no mode with HTTP and error capture disabled; metrics default to disabled in the `GitHub Runners` namespace while the rate-limit, job-retry, Spot-termination, and Spot-warning switches default to enabled. The two Spot switches are global termination-watcher settings and have no per-runner-config override. Other nullable runner-config observability fields inherit the global value. `observability.logs.tags` remains specific to runner-config-owned log groups; shared singleton functions receive global `tags` and `lambda.tags`. The nullness of `observability.tracing.mode` must be known during planning because it selects X-Ray IAM statements and tracing blocks in runner-config consumers. -The global `experimental.compute_provider` block owns v2 defaults for EC2 settings such as VPC and subnet IDs, managed-security-group behavior, egress rules, additional security groups, CloudWatch agent config, instance-profile path, key name, public IPv4 association, and tags. It also owns the shared AMI housekeeper, instance-termination watcher, and runner-binary distribution. It does not fall back to corresponding flat module inputs. VPC and subnet values must therefore be supplied through the global or per-runner-config EC2 block when needed. Global values should be set only when they are shared across every applicable runner config. The global block never selects a provider and does not contain provider-specific required runner-config fields. Every runner config must still populate exactly one typed provider block; that per-runner-config block selects the provider, supplies required fields such as EC2 `instance_types`, and preserves the per-runner selection point needed for future mixed-provider maps. +The global `experimental.compute_provider.aws.ec2` block owns v2 defaults for EC2 settings such as VPC and subnet IDs, managed-security-group behavior, egress rules, additional security groups, CloudWatch agent config, instance-profile path, key name, public IPv4 association, and tags. It also owns the shared AMI housekeeper, instance-termination watcher, and runner-binary distribution. It does not fall back to corresponding flat module inputs. VPC and subnet values must therefore be supplied through the global or per-runner-config `compute_provider.aws.ec2` block when needed. Global values should be set only when they are shared across every applicable runner config. The global `experimental.compute_provider` wrapper never selects a provider and does not contain provider-specific required runner-config fields outside its namespace leaves. Every runner config must still populate exactly one typed provider leaf; that per-runner-config leaf selects the provider, supplies required fields such as EC2 `instance_types`, and preserves the per-runner selection point needed for future mixed-provider maps. -`experimental.compute_provider.ec2.runner_binaries` owns whether EC2 runner configs use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable per-runner-config `compute_provider.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. +`experimental.compute_provider.aws.ec2.runner_binaries` owns whether EC2 runner configs use the shared binary distribution by default, distribution-bucket encryption, tags, versioning and access logging, and syncer artifact, Lambda sizing, and schedule. A nullable per-runner-config `compute_provider.aws.ec2.binaries_syncer.enabled` overrides only the global enable default. The resolved enable value, `runner_binaries.s3.encryption.enabled`, the nullness of its `kms_master_key_id`, and the nullness of `runner_binaries.s3.logging.bucket` must be known during planning because they control module or resource shape. KMS encryption grants the syncer access to the distribution key, but runner roles do not derive `kms:Decrypt` from that field; callers must attach decrypt permission to the module-managed or external runner roles separately. -Webhook-orchestration runner-control artifacts are selected globally through `experimental.orchestration_provider.webhook.lambda.artifact.zip` or `experimental.orchestration_provider.webhook.lambda.artifact.s3.{key,object_version}` and shared by scale, pool, and job-retry. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `experimental.multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The ingress webhook artifact remains separate under `experimental.orchestration_provider.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `experimental.compute_provider.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `experimental.compute_provider.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. +Webhook-orchestration runner-control artifacts are selected globally through `experimental.orchestration_provider.webhook.lambda.artifact.zip` or `experimental.orchestration_provider.webhook.lambda.artifact.s3.{key,object_version}` and shared by scale, pool, and job-retry. The S3 wrapper selects an object from the shared `experimental.lambda.artifact.s3.bucket`; null zip and S3 wrappers use the packaged runner archive. V2 validation rejects simultaneous zip and S3 selection and requires a non-null shared bucket and key when the S3 wrapper is present. Stable-mode translation preserves the old S3-wins rule by clearing the translated zip and creating the runner artifact's S3 wrapper whenever the flat `lambda_s3_bucket` is set. The shared bucket alone selects no component. Every artifact-capable singleton uses its own `artifact.s3` wrapper to supply that component's key and optional object version. Runner-config's common SSM housekeeper independently resolves `experimental.multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; S3 combines the component key and version with the shared artifact bucket, zip uses the selected local path, and no selection uses the packaged control-plane archive. Stable translation maps the existing runner artifact into this separate canonical component contract. The ingress webhook artifact remains separate under `experimental.orchestration_provider.webhook.lambda.webhook.artifact`; the runner-binary syncer uses the parallel `experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector, whose S3 key and optional object version resolve against the same shared bucket. `experimental.compute_provider.aws.ec2.instance_termination_watcher` owns watcher enablement, feature flags, runner deregistration, environment, artifact, and sizing. `experimental.compute_provider.aws.ec2.ami.housekeeper` owns enablement, cleanup behavior, artifact, sizing, and schedule. Watcher enablement and feature flags, runner-deregistration enablement, and AMI-housekeeper enablement must be known during planning because they control child resource shape. -Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-config tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes. Shared SSM merges `experimental.tags`, `ssm.tags`, and the forced `ghr:environment` tag. The webhook base resources merge `experimental.tags` with that environment tag; its Lambda additionally merges `experimental.lambda.tags` with `experimental.orchestration_provider.webhook.lambda.webhook.tags`. The runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags`, the environment tag, and `lambda.tags`. Distribution buckets additionally merge `compute_provider.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-runner-config `compute_provider.ec2.tags`. EC2 runtime tags belong under `compute_provider.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. +Tags follow the same ownership model but merge rather than replace. Within v2 webhook queue and runner-config scopes, experimental global tags merge with runner-config tags and then with orchestration component or subcomponent tags from broad to narrow; a narrower value wins for a duplicate key. Singleton shared resources use only global scopes. Shared SSM merges `experimental.tags`, `ssm.tags`, and the forced `ghr:environment` tag. The webhook base resources merge `experimental.tags` with that environment tag; its Lambda additionally merges `experimental.lambda.tags` with `experimental.orchestration_provider.webhook.lambda.webhook.tags`. The runner-binary syncer, termination watcher, and AMI housekeeper receive global `tags`, the environment tag, and `lambda.tags`. Distribution buckets additionally merge `compute_provider.aws.ec2.runner_binaries.s3.tags`. EC2 global provider tags merge with per-runner-config `compute_provider.aws.ec2.tags`. EC2 runtime tags belong under `compute_provider.aws.ec2.tags`; bootstrap tags required by the runner are reserved inside the provider and are not propagated to common resources. Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and runner-config log-group tags. Tracing stays under `observability.tracing`, and metrics enablement, namespace, and individual metric switches stay under `observability.metrics`. -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`, demand-control resources under `orchestration_provider.webhook`, and provider-specific resources under `provider.`. The provider key is derived from the selected typed compute-provider block. For example, a module-managed common runner role is available at `runners_map_v2[""].runner.role`; the value is null when the caller selects an external role. Scale-up resources are available at `runners_map_v2[""].orchestration_provider.webhook.scale_up`, and launch-template and runner-log artifacts are under `runners_map_v2[""].provider.ec2`. The top-level `scale_up`, `scale_down`, and `pool` fields remain compatibility aliases for their webhook-provider counterparts. The webhook `pool` value is null when no pool config is supplied. +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`, demand-control resources under `orchestration_provider.webhook`, and provider-specific resources under `provider..`. The provider namespace and type are derived from the selected typed compute-provider leaf. For example, a module-managed common runner role is available at `runners_map_v2[""].runner.role`; the value is null when the caller selects an external role. Scale-up resources are available at `runners_map_v2[""].orchestration_provider.webhook.scale_up`, and launch-template and runner-log artifacts are under `runners_map_v2[""].provider.aws.ec2`. The top-level `scale_up`, `scale_down`, and `pool` fields remain compatibility aliases for their webhook-provider counterparts. The webhook `pool` value is null when no pool config is supplied. Output references are configuration expressions rather than state addresses, so moved blocks cannot rewrite the former experimental `provider.ec2` path for consumers. ## Plan-time provider selection and IAM shape @@ -508,20 +512,22 @@ ssm = { } compute_provider = { - ec2 = { - ami = { - id_ssm_parameter = { - arn = aws_ssm_parameter.runner_ami.arn - } - kms_key = { - arn = aws_kms_key.runner_ami.arn + aws = { + ec2 = { + ami = { + id_ssm_parameter = { + arn = aws_ssm_parameter.runner_ami.arn + } + kms_key = { + arn = aws_kms_key.runner_ami.arn + } } } } } ``` -The populated `ec2` block tells both multi-runner routing and runner-config dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves that wrapper, and the `module.runner_configs` input forwards it unchanged at the runner-config boundary. The `orchestration_provider` wrapper follows the same exact-one rule. Within the compute block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. At this internal boundary, `ssm.kms_key_id`, the derived `orchestration_provider.webhook.queue.kms_key_id`, and values such as `observability.logs.kms_key_id` remain nullable scalar inputs even when their ARNs are unknown until apply. The public source of the derived queue key is `experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id`. +The populated `aws.ec2` leaf tells both multi-runner routing and runner-config dispatch which provider implementation exists and must therefore be known during planning. Canonical translation preserves both namespace levels, and the `module.runner_configs` input forwards the wrapper unchanged at the runner-config boundary. The `orchestration_provider` wrapper follows the same exact-one rule. Within the compute block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. At this internal boundary, `ssm.kms_key_id`, the derived `orchestration_provider.webhook.queue.kms_key_id`, and values such as `observability.logs.kms_key_id` remain nullable scalar inputs even when their ARNs are unknown until apply. The public source of the derived queue key is `experimental.orchestration_provider.webhook.queue.encryption.kms_master_key_id`. For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts shared GitHub App parameters created by the module, configures the webhook with the same key, and adds matching decrypt permissions to every runner config so its control-plane functions can read those credentials. Parameters selected through existing `*_ssm` references retain their external encryption and access requirements. The global key's value may be unknown until apply. It does not select encryption for runtime-created runner parameters. Queue encryption is a separate global contract, may use a different CMK, and reaches only the scale-up consumer and job-retry publisher policies inside the webhook orchestration provider. @@ -532,6 +538,6 @@ For experimental multi-runner v2, global `experimental.ssm.kms_key_id` encrypts 3. **Phase 3 — remove legacy variables and migrate state:** In a breaking release, remove the deprecated inputs and flat output adapter, route the remaining canonical configuration through `runner-config`, and ship tested `moved` blocks plus commands for addresses Terraform cannot move declaratively. 4. **Phase 4 — remove `modules/runners`:** After direct consumers have had a separate deprecation and migration window, delete the legacy module. -A future compute provider must add a typed external input block, multi-runner normalization and routing, a provider-specific `trust-policy` submodule, runner-config dispatch, and an integration that returns the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Today the typed schema exposes only `ec2`, so unsupported provider attributes fail input-schema validation. When another implemented block is added, the exact-one selection preconditions will reject runner configs that populate more than one supported compute provider. +A future compute provider must add a typed external namespace and provider leaf, multi-runner normalization and routing, a provider-specific `trust-policy` submodule, runner-config dispatch, and an integration that returns the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Today the typed schema exposes only `aws.ec2`, so unsupported namespace or provider attributes fail input-schema validation. When another implemented leaf is added, the exact-one selection preconditions will reject runner configs that populate more than one supported compute provider. A future orchestration provider must add a typed global-default block where shared settings are needed, a typed per-runner-config selector block, runner-config dispatch, a capability adapter for each supported compute provider, provider-grouped outputs, and focused routing and coexistence tests. Once a second typed orchestration provider exists, validation must also reject a runner config that selects more than one provider. diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md new file mode 100644 index 0000000000..58f3ccd03c --- /dev/null +++ b/modules/compute-providers/aws/ec2/README.md @@ -0,0 +1,80 @@ +# EC2 runner provider + +This internal module owns the EC2 compute implementation used by the common runner configuration. It creates the runner launch template, security group, instance profile, EC2 bootstrap parameters, and runner log groups. + +The module returns one nested `provider` contract. It groups EC2-specific Lambda settings under `environment_variables`, permission requirements under `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, and EC2 artifacts under `resources`. The parent runner configuration owns the shared runner role, provider-policy attachments, Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. + +EC2 is the only active compute provider. The parent runner configuration selects it when `aws.ec2` is the one populated typed leaf under `compute_provider`; no separate namespace or type input is required. Runner-config dispatches this module at `module.compute_aws_ec2[0]` from the `modules/compute-providers/aws/ec2` source and publishes its resources under `provider.aws.ec2`. A future provider must add its own typed namespace and provider leaf and implement the same contracts before it can be selected. + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_cloudwatch_log_group.gh_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_instance_profile.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource | +| [aws_iam_policy.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_launch_template.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template) | resource | +| [aws_security_group.runner_sg](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_ssm_parameter.cloudwatch_agent_config_runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.runner_ami_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.runner_config_run_as](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.runner_enable_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_runner](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.create_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.distribution_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.service_linked_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.session_manager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.terminate_self](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no | +| [aws\_region](#input\_aws\_region) | AWS region used by compute-provider resources and policy documents. | `string` | n/a | yes | +| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 available to compute-provider 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 available to compute-provider runner log groups.

- `logs.retention_in_days`: Retention period for provider-owned 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 identify resources created for the runner configuration. | `string` | `"github-actions"` | no | +| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture.
- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-config manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
- `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | +| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `paths.root`: Root Parameter Store path for the runner configuration.
- `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 available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | +| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | +| [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | +| [resources](#output\_resources) | Provider-specific EC2 resources exposed by runner-config. | + diff --git a/modules/compute-providers/ec2/control-plane.tf b/modules/compute-providers/aws/ec2/control-plane.tf similarity index 100% rename from modules/compute-providers/ec2/control-plane.tf rename to modules/compute-providers/aws/ec2/control-plane.tf diff --git a/modules/compute-providers/ec2/instance-profile.tf b/modules/compute-providers/aws/ec2/instance-profile.tf similarity index 100% rename from modules/compute-providers/ec2/instance-profile.tf rename to modules/compute-providers/aws/ec2/instance-profile.tf diff --git a/modules/compute-providers/ec2/logging.tf b/modules/compute-providers/aws/ec2/logging.tf similarity index 100% rename from modules/compute-providers/ec2/logging.tf rename to modules/compute-providers/aws/ec2/logging.tf diff --git a/modules/compute-providers/ec2/outputs.tf b/modules/compute-providers/aws/ec2/outputs.tf similarity index 100% rename from modules/compute-providers/ec2/outputs.tf rename to modules/compute-providers/aws/ec2/outputs.tf diff --git a/modules/compute-providers/ec2/policies-runner.tf b/modules/compute-providers/aws/ec2/policies-runner.tf similarity index 100% rename from modules/compute-providers/ec2/policies-runner.tf rename to modules/compute-providers/aws/ec2/policies-runner.tf diff --git a/modules/compute-providers/ec2/provider-contract.tf b/modules/compute-providers/aws/ec2/provider-contract.tf similarity index 100% rename from modules/compute-providers/ec2/provider-contract.tf rename to modules/compute-providers/aws/ec2/provider-contract.tf diff --git a/modules/compute-providers/ec2/runner-config.tf b/modules/compute-providers/aws/ec2/runner-config.tf similarity index 100% rename from modules/compute-providers/ec2/runner-config.tf rename to modules/compute-providers/aws/ec2/runner-config.tf diff --git a/modules/compute-providers/ec2/runner-instances.tf b/modules/compute-providers/aws/ec2/runner-instances.tf similarity index 100% rename from modules/compute-providers/ec2/runner-instances.tf rename to modules/compute-providers/aws/ec2/runner-instances.tf diff --git a/modules/compute-providers/ec2/templates/cloudwatch_config.json b/modules/compute-providers/aws/ec2/templates/cloudwatch_config.json similarity index 100% rename from modules/compute-providers/ec2/templates/cloudwatch_config.json rename to modules/compute-providers/aws/ec2/templates/cloudwatch_config.json diff --git a/modules/compute-providers/ec2/templates/install-runner-osx.sh b/modules/compute-providers/aws/ec2/templates/install-runner-osx.sh similarity index 100% rename from modules/compute-providers/ec2/templates/install-runner-osx.sh rename to modules/compute-providers/aws/ec2/templates/install-runner-osx.sh diff --git a/modules/compute-providers/ec2/templates/install-runner.ps1 b/modules/compute-providers/aws/ec2/templates/install-runner.ps1 similarity index 100% rename from modules/compute-providers/ec2/templates/install-runner.ps1 rename to modules/compute-providers/aws/ec2/templates/install-runner.ps1 diff --git a/modules/compute-providers/ec2/templates/install-runner.sh b/modules/compute-providers/aws/ec2/templates/install-runner.sh similarity index 100% rename from modules/compute-providers/ec2/templates/install-runner.sh rename to modules/compute-providers/aws/ec2/templates/install-runner.sh diff --git a/modules/compute-providers/ec2/templates/start-runner-osx.sh b/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh similarity index 100% rename from modules/compute-providers/ec2/templates/start-runner-osx.sh rename to modules/compute-providers/aws/ec2/templates/start-runner-osx.sh diff --git a/modules/compute-providers/ec2/templates/start-runner.ps1 b/modules/compute-providers/aws/ec2/templates/start-runner.ps1 similarity index 100% rename from modules/compute-providers/ec2/templates/start-runner.ps1 rename to modules/compute-providers/aws/ec2/templates/start-runner.ps1 diff --git a/modules/compute-providers/ec2/templates/start-runner.sh b/modules/compute-providers/aws/ec2/templates/start-runner.sh similarity index 100% rename from modules/compute-providers/ec2/templates/start-runner.sh rename to modules/compute-providers/aws/ec2/templates/start-runner.sh diff --git a/modules/compute-providers/ec2/templates/user-data-osx.sh b/modules/compute-providers/aws/ec2/templates/user-data-osx.sh similarity index 100% rename from modules/compute-providers/ec2/templates/user-data-osx.sh rename to modules/compute-providers/aws/ec2/templates/user-data-osx.sh diff --git a/modules/compute-providers/ec2/templates/user-data.ps1 b/modules/compute-providers/aws/ec2/templates/user-data.ps1 similarity index 100% rename from modules/compute-providers/ec2/templates/user-data.ps1 rename to modules/compute-providers/aws/ec2/templates/user-data.ps1 diff --git a/modules/compute-providers/ec2/templates/user-data.sh b/modules/compute-providers/aws/ec2/templates/user-data.sh similarity index 100% rename from modules/compute-providers/ec2/templates/user-data.sh rename to modules/compute-providers/aws/ec2/templates/user-data.sh diff --git a/modules/compute-providers/ec2/tests/provider.tftest.hcl b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl similarity index 100% rename from modules/compute-providers/ec2/tests/provider.tftest.hcl rename to modules/compute-providers/aws/ec2/tests/provider.tftest.hcl diff --git a/modules/compute-providers/ec2/trust-policy/README.md b/modules/compute-providers/aws/ec2/trust-policy/README.md similarity index 93% rename from modules/compute-providers/ec2/trust-policy/README.md rename to modules/compute-providers/aws/ec2/trust-policy/README.md index f73dc49b9b..2ead8d1504 100644 --- a/modules/compute-providers/ec2/trust-policy/README.md +++ b/modules/compute-providers/aws/ec2/trust-policy/README.md @@ -6,14 +6,14 @@ This internal submodule builds the EC2 runner-role trust policy independently fr ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | @@ -24,7 +24,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [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.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -32,12 +32,12 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the default EC2 runner-role trust policy. | `string` | `null` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [assume\_role\_policy](#output\_assume\_role\_policy) | EC2 runner-role trust policy with the optional additional trust policy merged into it. | diff --git a/modules/compute-providers/ec2/trust-policy/assume-role.tf b/modules/compute-providers/aws/ec2/trust-policy/assume-role.tf similarity index 100% rename from modules/compute-providers/ec2/trust-policy/assume-role.tf rename to modules/compute-providers/aws/ec2/trust-policy/assume-role.tf diff --git a/modules/compute-providers/ec2/trust-policy/outputs.tf b/modules/compute-providers/aws/ec2/trust-policy/outputs.tf similarity index 100% rename from modules/compute-providers/ec2/trust-policy/outputs.tf rename to modules/compute-providers/aws/ec2/trust-policy/outputs.tf diff --git a/modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl b/modules/compute-providers/aws/ec2/trust-policy/tests/trust-policy.tftest.hcl similarity index 100% rename from modules/compute-providers/ec2/trust-policy/tests/trust-policy.tftest.hcl rename to modules/compute-providers/aws/ec2/trust-policy/tests/trust-policy.tftest.hcl diff --git a/modules/compute-providers/ec2/trust-policy/validations.tf b/modules/compute-providers/aws/ec2/trust-policy/validations.tf similarity index 100% rename from modules/compute-providers/ec2/trust-policy/validations.tf rename to modules/compute-providers/aws/ec2/trust-policy/validations.tf diff --git a/modules/compute-providers/ec2/trust-policy/variables.tf b/modules/compute-providers/aws/ec2/trust-policy/variables.tf similarity index 100% rename from modules/compute-providers/ec2/trust-policy/variables.tf rename to modules/compute-providers/aws/ec2/trust-policy/variables.tf diff --git a/modules/compute-providers/ec2/trust-policy/versions.tf b/modules/compute-providers/aws/ec2/trust-policy/versions.tf similarity index 100% rename from modules/compute-providers/ec2/trust-policy/versions.tf rename to modules/compute-providers/aws/ec2/trust-policy/versions.tf diff --git a/modules/compute-providers/ec2/validations.tf b/modules/compute-providers/aws/ec2/validations.tf similarity index 71% rename from modules/compute-providers/ec2/validations.tf rename to modules/compute-providers/aws/ec2/validations.tf index ff59bafc13..0836cf0f7a 100644 --- a/modules/compute-providers/ec2/validations.tf +++ b/modules/compute-providers/aws/ec2/validations.tf @@ -2,7 +2,7 @@ resource "terraform_data" "validate_config" { lifecycle { precondition { condition = contains(["spot", "on-demand"], var.config.instance_target_capacity_type) - error_message = "compute_provider.ec2.instance_target_capacity_type must be spot or on-demand." + error_message = "compute_provider.aws.ec2.instance_target_capacity_type must be spot or on-demand." } precondition { @@ -10,12 +10,12 @@ resource "terraform_data" "validate_config" { ["lowest-price", "diversified", "capacity-optimized", "capacity-optimized-prioritized", "price-capacity-optimized", "prioritized"], var.config.instance_allocation_strategy, ) - error_message = "compute_provider.ec2.instance_allocation_strategy is not supported." + error_message = "compute_provider.aws.ec2.instance_allocation_strategy is not supported." } precondition { condition = var.config.credit_specification == null ? true : contains(["standard", "unlimited"], var.config.credit_specification) - error_message = "compute_provider.ec2.credit_specification must be null, standard, or unlimited." + error_message = "compute_provider.aws.ec2.credit_specification must be null, standard, or unlimited." } precondition { @@ -23,17 +23,17 @@ resource "terraform_data" "validate_config" { (var.config.cpu_options.amd_sev_snp == null ? true : contains(["enabled", "disabled"], var.config.cpu_options.amd_sev_snp)) && (var.config.cpu_options.nested_virtualization == null ? true : contains(["enabled", "disabled"], var.config.cpu_options.nested_virtualization)) ) - error_message = "compute_provider.ec2.cpu_options amd_sev_snp and nested_virtualization must be enabled or disabled when set." + error_message = "compute_provider.aws.ec2.cpu_options amd_sev_snp and nested_virtualization must be enabled or disabled when set." } precondition { condition = !var.config.binaries_syncer.enabled || var.config.binaries_syncer.s3 != null - error_message = "compute_provider.ec2.binaries_syncer.s3 must be set when compute_provider.ec2.binaries_syncer.enabled is true." + error_message = "compute_provider.aws.ec2.binaries_syncer.s3 must be set when compute_provider.aws.ec2.binaries_syncer.enabled is true." } precondition { condition = var.config.instance_profile == null || !var.runner.iam.role.managed - error_message = "runner.iam.role must be set when compute_provider.ec2.instance_profile selects an external instance profile." + error_message = "runner.iam.role must be set when compute_provider.aws.ec2.instance_profile selects an external instance profile." } } } diff --git a/modules/compute-providers/ec2/variables.tf b/modules/compute-providers/aws/ec2/variables.tf similarity index 99% rename from modules/compute-providers/ec2/variables.tf rename to modules/compute-providers/aws/ec2/variables.tf index c686049d79..f538d5026e 100644 --- a/modules/compute-providers/ec2/variables.tf +++ b/modules/compute-providers/aws/ec2/variables.tf @@ -23,7 +23,7 @@ variable "tags" { variable "config" { description = <<-EOT - EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner configuration. + EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration. - `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`. - `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults. diff --git a/modules/compute-providers/ec2/versions.tf b/modules/compute-providers/aws/ec2/versions.tf similarity index 100% rename from modules/compute-providers/ec2/versions.tf rename to modules/compute-providers/aws/ec2/versions.tf diff --git a/modules/compute-providers/ec2/README.md b/modules/compute-providers/ec2/README.md deleted file mode 100644 index 1963eea11f..0000000000 --- a/modules/compute-providers/ec2/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# EC2 runner provider - -This internal module owns the EC2 compute implementation used by the common runner configuration. It creates the runner launch template, security group, instance profile, EC2 bootstrap parameters, and runner log groups. - -The module returns one nested `provider` contract. It groups EC2-specific Lambda settings under `environment_variables`, permission requirements under `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, and EC2 artifacts under `resources`. The parent runner configuration owns the shared runner role, provider-policy attachments, Lambda functions, execution roles, schedules, queues, retry flow, and SSM housekeeper. - -EC2 is the only active compute provider. The parent runner configuration selects it when `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.4.0 | -| [aws](#requirement\_aws) | >= 6.33 | - -## Providers - -| Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [terraform](#provider\_terraform) | n/a | - -## Modules - -No modules. - -## Resources - -| Name | Type | -|------|------| -| [aws_cloudwatch_log_group.gh_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_iam_instance_profile.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource | -| [aws_iam_policy.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | -| [aws_launch_template.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template) | resource | -| [aws_security_group.runner_sg](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | -| [aws_ssm_parameter.cloudwatch_agent_config_runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | -| [aws_ssm_parameter.runner_ami_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | -| [aws_ssm_parameter.runner_config_run_as](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | -| [aws_ssm_parameter.runner_enable_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | -| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | -| [terraform_data.validate_runner](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | -| [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | -| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | -| [aws_iam_policy_document.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.create_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.distribution_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.scale_up](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.service_linked_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.session_manager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.ssm_parameters](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.terminate_self](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no | -| [aws\_region](#input\_aws\_region) | AWS region used by compute-provider resources and policy documents. | `string` | n/a | yes | -| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 available to compute-provider 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 available to compute-provider runner log groups.

- `logs.retention_in_days`: Retention period for provider-owned 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 identify resources created for the runner configuration. | `string` | `"github-actions"` | no | -| [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture.
- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-config manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
- `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | -| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `paths.root`: Root Parameter Store path for the runner configuration.
- `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 available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | - -## Outputs - -| Name | Description | -|------|-------------| -| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | -| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | -| [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | -| [resources](#output\_resources) | Provider-specific EC2 resources exposed by runner-config. | - diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 493ff7fc1b..d625074bfc 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -25,19 +25,19 @@ See [Experimental orchestration- and compute-provider refactor](https://github-a 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. -A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-config` at `module.runner_configs["configuration"]`; earlier experimental module addresses are not migrated automatically. Sibling `experimental.tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each runner configuration can override supported configuration-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides affect only that runner configuration, never a singleton shared component. A nullable configuration field inherits its global value. Within v2 queue and runner-config scopes, tags merge from global to configuration and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. +A non-empty `experimental.multi_runner_config` opts the whole module instance into v2 and takes priority over the stable top-level `multi_runner_config`; entries from the maps are never combined. V2 uses `modules/runner-config` at `module.runner_configs["configuration"]`. Inside each v2 runner config, moved blocks preserve EC2 provider child state when the earlier `module.compute_ec2_trust_policy[0]` and `module.compute_ec2[0]` labels become `module.compute_aws_ec2_trust_policy[0]` and `module.compute_aws_ec2[0]`; unrelated earlier experimental addresses are not migrated automatically. Sibling `experimental.tags`, `roles`, `runner`, `github`, `lambda`, `orchestration_provider`, `ssm`, `observability`, and `compute_provider` blocks provide global defaults, while each runner configuration can override supported configuration-owned values. The global translated values also configure the singleton GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. Migrated v2 consumers do not fall back to matching flat inputs; those flat values seed stable-mode translation only. Per-configuration overrides affect only that runner configuration, never a singleton shared component. A nullable configuration field inherits its global value. Within v2 queue and runner-config scopes, tags merge from global to configuration and then from broad component scopes to narrow ones. `experimental.tags` is also the base tag map for the translated shared components. -The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, configuration-over-global precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, the webhook-owned runner-control artifact, Lambda principals and shared artifact bucket, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references under `orchestration_provider.webhook`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { ec2 = ... }` contract. +The implementation has one canonical configuration pipeline in `config.experimental.translation.tf`. A non-empty experimental runner-configuration map selects v2; otherwise the file projects flat globals and stable `multi_runner_config` entries into the same schema. That selection produces `local.raw_translated_experimental`, from which the same file derives `local.translated_experimental_base`, applying schema defaults, configuration-over-global precedence, tag merges, IAM ownership, paths, observability, queues, and provider defaults before plan-shaping discovery. Provider selection and the shared runner-binary syncer and discovery consume this base. After discovery, the translation file derives the final `local.translated_experimental`, including labels, runner-config GitHub client settings, the webhook-owned runner-control artifact, Lambda principals and shared artifact bucket, SSM KMS, and the discovered EC2 binaries object. The remaining shared components, queues, and runner implementations consume this final canonical representation. Stable runner configurations are adapted back into the existing `module.runners["configuration"]` call, so their Terraform addresses remain unchanged. The `module.runner_configs` call directly iterates the gated final runner configurations, inlines the environment tag and live GitHub App and build-queue references under `orchestration_provider.webhook`, and forwards the remaining canonical objects, including the wrapped `compute_provider = { aws = { ec2 = ... } }` contract. V2 requires `experimental.github.app`, and `experimental.github.additional_apps` defaults to `[]`. These nested values are authoritative end-to-end for the shared Parameter Store module and v2 runner configurations; flat `github_app` and `additional_github_apps` seed stable-mode translation only. `experimental.orchestration_provider.webhook.github.repository_white_list` defaults to `[]` and filters the shared webhook when populated. `experimental.github.enterprise_server.url` defaults to `null` and configures both v2 runner-config GitHub clients and the shared termination watcher. `experimental.github.enterprise_server.ssl_verify` and `experimental.github.user_agent` remain runner-config client settings and default to `true` and `github-aws-runners`. The shared webhook does not make GitHub API requests. -Shared singleton resources use the translated global contract without accepting per-configuration overrides. The shared webhook and its webhook-secret parameter retain their historical singleton addresses and are always created; only build-queue and matcher membership is filtered to runner configurations that select `orchestration_provider.webhook`, so future non-webhook providers do not change the singleton lifecycle. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Global `experimental.lambda` contains only that shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults. Workflow-job webhook control-plane defaults live under `experimental.orchestration_provider.webhook`. The runner-control artifact shared by scale, pool, and job-retry comes from `experimental.orchestration_provider.webhook.lambda.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Each runner configuration's SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the same shared artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation maps the legacy runner artifact into both canonical runner-control and SSM-housekeeper component contracts while preserving S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the ingress webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. `experimental.orchestration_provider.webhook` owns runner lifecycle, repository filtering, queue selection, EventBridge routing, accepted event types, matcher-parameter tier, queue defaults, and webhook/scale/pool Lambda component defaults. Its `lambda.webhook` block owns the ingress webhook's separate artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.ec2.instance_termination_watcher`, `compute_provider.ec2.ami.housekeeper`, and `compute_provider.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. +Shared singleton resources use the translated global contract without accepting per-configuration overrides. The shared webhook and its webhook-secret parameter retain their historical singleton addresses and are always created; only build-queue and matcher membership is filtered to runner configurations that select `orchestration_provider.webhook`, so future non-webhook providers do not change the singleton lifecycle. The shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper consume global Lambda runtime, architecture, networking, role, tags, logging, and tracing settings; `lambda.principals` additionally configures runner-config, runner-binary-syncer, termination-watcher, and AMI-housekeeper roles, but not the webhook role. Global metrics also configure the termination watcher. Global `experimental.lambda` contains only that shared Lambda substrate: the artifact bucket, runtime, architecture, principals, networking, tags, and role defaults. Workflow-job webhook control-plane defaults live under `experimental.orchestration_provider.webhook`. The runner-control artifact shared by scale, pool, and job-retry comes from `experimental.orchestration_provider.webhook.lambda.artifact.{zip,s3}`; the S3 wrapper supplies `key` and optional `object_version` for the shared `experimental.lambda.artifact.s3.bucket`. Each runner configuration's SSM housekeeper independently resolves `multi_runner_config[].ssm.housekeeper.lambda.artifact` over the global `experimental.ssm.housekeeper.lambda.artifact`; an S3 selection combines its key and optional object version with the same shared artifact bucket, a zip selection uses its local path, and no selection uses runner-config's packaged runner control-plane archive. The module validates that zip and S3 are mutually exclusive and that an S3 wrapper has a non-null shared bucket and key. Stable-mode translation maps the legacy runner artifact into both canonical runner-control and SSM-housekeeper component contracts while preserving S3-over-zip precedence. The shared bucket alone selects no component. Each artifact-capable singleton—including the ingress webhook—uses it only when its separate nested `artifact.s3` wrapper supplies that component's key and optional object version. The runner-binary syncer uses the parallel `experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.{zip,s3}` selector; its S3 key and optional object version also resolve against the shared bucket. `experimental.orchestration_provider.webhook` owns runner lifecycle, repository filtering, queue selection, EventBridge routing, accepted event types, matcher-parameter tier, queue defaults, and webhook/scale/pool Lambda component defaults. Its `lambda.webhook` block owns the ingress webhook's separate artifact selection, API Gateway access logs, sizing, and component tags. `compute_provider.aws.ec2.instance_termination_watcher`, `compute_provider.aws.ec2.ami.housekeeper`, and `compute_provider.aws.ec2.runner_binaries` own their component-specific behavior, artifacts, sizing, schedules, and related settings. Only `prefix`, `aws_partition`, and `aws_region` remain active flat-only inputs; legacy `iam_overrides` remains in the input schema but has no active consumer. Global `ssm.paths.root` is the base for the shared GitHub App and webhook parameters and for configuration-owned parameters. The shared resources append `ssm.paths.app` (default `app`) or `ssm.paths.webhook` (default `webhook`); normalization appends the configuration key only to runner-configuration roots. The default derived base is `/github-action-runners/${prefix}`, and runner-configuration token/config segments default to `runners/tokens` and `runners/config`. Global `ssm.kms_key_id` is an optional KMS-key ARN that encrypts the shared GitHub App parameters, configures the webhook, and adds matching decrypt permissions to every runner configuration. It does not select encryption for runtime-created runner-configuration parameters. Provider-owned runner-config IAM omits the KMS statement when the key is null and still accepts an ARN whose value is unknown until apply; the unchanged shared webhook retains its legacy policy handling. -Each runner configuration selects exactly one typed `orchestration_provider` provider and exactly one typed `compute_provider`; the corresponding global blocks supply defaults without selecting providers. `runner-config` validates both selections, owns the common runner role and SSM housekeeper, and connects compute-provider capabilities to the selected orchestration provider. `orchestration-providers/webhook` owns scale-up, scale-down, pool, and job-retry resources. The EC2 provider owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. Provider-block selection must be known during planning because it determines the module graph. These child modules are internal implementation boundaries rather than standalone public entry points. EC2 remains the only Terraform-managed runner provider; MicroVM, CodeBuild, and other provider modules are future work. +Each runner configuration selects exactly one typed `orchestration_provider` provider and exactly one namespaced `compute_provider` leaf; the corresponding global blocks supply defaults without selecting providers. `runner-config` validates both selections, owns the common runner role and SSM housekeeper, and connects compute-provider capabilities to the selected orchestration provider. `orchestration-providers/webhook` owns scale-up, scale-down, pool, and job-retry resources. The EC2 implementation at `compute-providers/aws/ec2` owns EC2 policy requirements, the instance profile, launch template, bootstrap resources, and provider-specific Lambda fragments. Provider-leaf selection must be known during planning because it determines the module graph. These child modules are internal implementation boundaries 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 runner-config 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.aws.ec2.instance_profile`. Provider policy documents are generated internally and attached by runner-config when it creates the role. An external role remains unmanaged and must already contain the required policies. Phase 1 supports both input contracts with deterministic precedence. When `experimental.multi_runner_config` is empty, the stable top-level `multi_runner_config` follows the unchanged legacy path. When the experimental map is non-empty, it becomes the complete runner map and stable entries are ignored. The maps are not merged. @@ -45,39 +45,39 @@ Global `experimental.orchestration_provider.webhook.queue` owns the v2 defaults For v2, `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds` controls build-queue visibility independently from `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`. Keep visibility at least six times the resolved scale-up Lambda timeout; the module validates this relationship. The v1 translation preserves the flat behavior by using `runners_scale_up_lambda_timeout` for build-queue visibility and `queue_encryption` for encryption. -Global `experimental.compute_provider.ec2.runner_binaries` owns the shared distribution-bucket and syncer defaults. It covers runner-configuration enablement, bucket encryption, tags, versioning and access logging, plus the syncer artifact, Lambda sizing, and schedule. `runner_binaries.enabled` defaults to `true`, while a nullable per-configuration `compute_provider.ec2.binaries_syncer.enabled` overrides that default; enabled distributions are created once per unique operating-system and architecture pair. The resolved enable value, `s3.encryption.enabled`, the nullness of `s3.encryption.kms_master_key_id`, and the nullness of `s3.logging.bucket` must be known during planning because they determine module or resource shape. A distribution CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from it; attach that permission separately when using a CMK. +Global `experimental.compute_provider.aws.ec2.runner_binaries` owns the shared distribution-bucket and syncer defaults. It covers runner-configuration enablement, bucket encryption, tags, versioning and access logging, plus the syncer artifact, Lambda sizing, and schedule. `runner_binaries.enabled` defaults to `true`, while a nullable per-configuration `compute_provider.aws.ec2.binaries_syncer.enabled` overrides that default; enabled distributions are created once per unique operating-system and architecture pair. The resolved enable value, `s3.encryption.enabled`, the nullness of `s3.encryption.kms_master_key_id`, and the nullness of `s3.logging.bucket` must be known during planning because they determine module or resource shape. A distribution CMK grants the syncer access, but runner roles do not derive `kms:Decrypt` from it; attach that permission separately when using a CMK. ### V2 tagging -For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `orchestration_provider.webhook.queue.tags`, and configuration-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `orchestration_provider.webhook.lambda.scale.up.tags`, `orchestration_provider.webhook.lambda.scale.down.tags`, `orchestration_provider.webhook.lambda.webhook.tags`, `orchestration_provider.webhook.lambda.pool.tags`, `orchestration_provider.webhook.job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `orchestration_provider.webhook.lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain configuration-owned runner-config log-group tags. In stable mode, translation preserves the existing flat behavior. +For v2 runner configurations, `experimental.tags` are merged with configuration `tags`. Shared `lambda.tags`, `orchestration_provider.webhook.queue.tags`, and configuration-resolved `observability.logs.tags` are then merged with component tags such as `runner.tags`, `orchestration_provider.webhook.lambda.scale.up.tags`, `orchestration_provider.webhook.lambda.scale.down.tags`, `orchestration_provider.webhook.lambda.webhook.tags`, `orchestration_provider.webhook.lambda.pool.tags`, `orchestration_provider.webhook.job_retry.tags`, and the nested SSM tag scopes. Narrower scopes win repeated keys. Queue tags apply to the configuration build queue and dead-letter queue owned by multi-runner. The shared GitHub App Parameter Store module merges `experimental.tags` with `ssm.tags`; the webhook merges `lambda.tags` with `orchestration_provider.webhook.lambda.webhook.tags`; the runner-binary syncer, termination watcher, and AMI housekeeper use global `tags` and `lambda.tags`, while `compute_provider.aws.ec2.runner_binaries.s3.tags` adds distribution-bucket-only tags. Observability log tags remain configuration-owned runner-config log-group tags. In stable mode, translation preserves the existing flat behavior. -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`, with demand-control resources canonically grouped under `orchestration_provider.webhook`, while `runners_map` is empty. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].orchestration_provider.webhook.scale_up.lambda`, `.log_group`, and `.role` for scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. Existing flat `scale_up`, `scale_down`, and `pool` output aliases remain available for compatibility. Provider-specific resources are grouped under the selected provider key, which also identifies the compute provider. For EC2, launch-template and runner-log resources remain under `runners_map_v2["configuration"].provider.ec2`. +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`, with demand-control resources canonically grouped under `orchestration_provider.webhook`, while `runners_map` is empty. Use `runners_map_v2["configuration"].runner.role` for the common runner role and `runners_map_v2["configuration"].orchestration_provider.webhook.scale_up.lambda`, `.log_group`, and `.role` for scale-up resources. The same resource shape is used for `scale_down` and an enabled `pool`; `pool` is null when it is disabled. Existing flat `scale_up`, `scale_down`, and `pool` output aliases remain available for compatibility. Provider-specific resources are grouped by namespace and provider type. For EC2, launch-template and runner-log resources are under `runners_map_v2["configuration"].provider.aws.ec2`. Moved blocks preserve the renamed provider child-module state, but cannot rewrite references from the former experimental `provider.ec2` output path. ### Multi-runner v2 migration roadmap Here, v1 and v2 refer to the stable top-level `multi_runner_config` and `experimental.multi_runner_config`, 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 as a module-level opt-in (current) +#### Phase 1 — V2 opt-in and canonical translation (current) Both input contracts are available in the same module release. An empty `experimental.multi_runner_config` keeps every stable top-level `multi_runner_config` entry on the existing `modules/runners` implementation at `module.runners["configuration"]`, retaining its flat `runners_map` output and Terraform addresses. Internally, the flat input is projected through `local.translated_experimental_base` and finalized as `local.translated_experimental`; `runners.tf` adapts those canonical runner configurations to the existing module call instead of forwarding the original v1 object. A non-empty experimental map selects `module.runner_configs["configuration"]` and the nested `runners_map_v2` output shape; it takes priority over stable entries, and the maps are not combined. -Compatibility guarantee: upgrading while leaving `experimental.multi_runner_config` 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. +Compatibility guarantee: upgrading while leaving `experimental.multi_runner_config` 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; the v2 opt-in is for new or explicitly experimental deployments. -#### Phase 2 — Translate v1 and migrate state +#### Phase 2 — Deprecate legacy variables -`multi_runner_config` remains accepted but is deprecated, and its existing translated representation becomes the dispatch source for `runner-config`. 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. +Deprecate the stable `multi_runner_config` and migrated flat inputs while retaining both dispatch paths and compatibility outputs for a release window. Both input modes already use the canonical translation pipeline, so this phase changes lifecycle guidance rather than introducing a second translation. -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. +Compatibility guarantee: existing stable deployments remain on `module.runners` while users receive a deprecation window before the breaking migration. -#### Phase 3 — Remove v1 from multi-runner +#### Phase 3 — Remove legacy variables and migrate state -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. +In a breaking release, remove the deprecated inputs and flat output adapter, route the remaining canonical configuration through `runner-config`, and ship tested `moved` blocks plus exact commands for addresses Terraform cannot move declaratively. -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. +Compatibility guarantee: phase 3 will not be released together with phase 2. Users will have at least one release in which the legacy variables are deprecated before their removal and state migration. -#### Future — Retire the legacy runners module +#### Phase 4 — Remove `modules/runners` -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. +After direct consumers have had a separate deprecation and migration window, delete the legacy module. This remains distinct from the multi-runner contract migration. For each configuration: @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,7 +167,7 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | @@ -175,7 +175,7 @@ module "multi-runner" { ## 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 | @@ -187,7 +187,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 | @@ -199,7 +199,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -215,7 +215,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null.
- `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration.
- `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].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.
- `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
})

})), {})
})
| `{}` | 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null.
- `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider`: Shared compute-provider defaults grouped first by cloud and then by provider type. Global defaults do not select a provider for any runner configuration.
- `compute_provider.aws`: Shared defaults for AWS compute providers.
- `compute_provider.aws.ec2`: Shared defaults for AWS EC2 runner configurations.
- `compute_provider.aws.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.aws.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.aws.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.aws.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.aws.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.aws.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.aws.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.aws.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.aws.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.aws.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.aws.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.aws.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.aws.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.aws.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.aws.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.aws.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.aws.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.aws.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.aws.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].compute_provider`: Typed compute-provider namespaces. Exactly one nested provider block must be non-null, and that populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply.
- `multi_runner_config[].compute_provider.aws`: AWS compute-provider namespace. The namespace itself does not select a provider.
- `multi_runner_config[].compute_provider.aws.ec2`: AWS EC2-specific configuration. A non-null block selects AWS EC2 for this runner configuration.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.aws.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.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.
- `multi_runner_config[].compute_provider.aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.aws.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.aws.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.aws.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.aws.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.aws.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.aws.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.aws.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.aws.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].compute_provider.aws.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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

compute_provider = object({
aws = optional(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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
}), {})
})

})), {})
})
| `{}` | 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
}))
})
| `null` | no | @@ -284,12 +284,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) | 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. The `orchestration_provider` object is canonical; `scale_up`, `scale_down`, and `pool` remain compatibility aliases. | +| [runners\_map\_v2](#output\_runners\_map\_v2) | Experimental v2 runner resources keyed by runner configuration. Compute resources are grouped under `provider..`, currently `provider.aws.ec2`. The `orchestration_provider` object is canonical; `scale_up`, `scale_down`, and `pool` remain compatibility aliases. | | [ssm\_parameters](#output\_ssm\_parameters) | n/a | | [webhook](#output\_webhook) | n/a | diff --git a/modules/multi-runner/ami-housekeeper.tf b/modules/multi-runner/ami-housekeeper.tf index 89365d275d..a3bb0245bc 100644 --- a/modules/multi-runner/ami-housekeeper.tf +++ b/modules/multi-runner/ami-housekeeper.tf @@ -1,24 +1,24 @@ module "ami_housekeeper" { - count = try(local.translated_experimental.compute_provider.ec2.ami.housekeeper.enabled, false) ? 1 : 0 + count = try(local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.enabled, false) ? 1 : 0 source = "../ami-housekeeper" prefix = var.prefix tags = merge(local.translated_experimental.tags, { "ghr:environment" = var.prefix }) aws_partition = var.aws_partition - lambda_zip = local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.zip - lambda_s3_bucket = local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket - lambda_s3_key = try(local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key, null) - lambda_s3_object_version = try(local.translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.object_version, null) + lambda_zip = local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.zip + lambda_s3_bucket = local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + lambda_s3_key = try(local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key, null) + lambda_s3_object_version = try(local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version, null) lambda_architecture = local.translated_experimental.lambda.architecture lambda_principals = local.translated_experimental.lambda.principals lambda_runtime = local.translated_experimental.lambda.runtime lambda_security_group_ids = local.translated_experimental.lambda.security_group_ids lambda_subnet_ids = local.translated_experimental.lambda.subnet_ids - lambda_memory_size = local.translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.memory_size - lambda_timeout = local.translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.timeout + lambda_memory_size = local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size + lambda_timeout = local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.lambda.timeout lambda_tags = local.translated_experimental.lambda.tags tracing_config = local.translated_experimental.observability.tracing @@ -30,6 +30,6 @@ module "ami_housekeeper" { role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) - cleanup_config = local.translated_experimental.compute_provider.ec2.ami.housekeeper.cleanup_config - lambda_schedule_expression = local.translated_experimental.compute_provider.ec2.ami.housekeeper.schedule.expression + cleanup_config = local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.cleanup_config + lambda_schedule_expression = local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.schedule.expression } diff --git a/modules/multi-runner/compute-provider.tf b/modules/multi-runner/compute-provider.tf index 5e94067e8c..6f4531aa1f 100644 --- a/modules/multi-runner/compute-provider.tf +++ b/modules/multi-runner/compute-provider.tf @@ -1,16 +1,35 @@ locals { + compute_provider_selections = { + for runner_key, runner_config in local.translated_experimental_base.multi_runner_config : runner_key => try(one(flatten([ + for provider_namespace, provider_configs in runner_config.compute_provider : [ + for provider_type, provider_config in provider_configs : { + namespace = provider_namespace + type = provider_type + key = "${provider_namespace}_${provider_type}" + } + if provider_config != null + ] + ])), null) + } + + compute_provider_keys = { + for runner_key, selection in local.compute_provider_selections : + runner_key => try(selection.key, null) + } + + # The webhook runtime registry remains provider-type based. Terraform-only + # dispatch keys include the provider namespace so future clouds can expose + # similarly named compute services without colliding. compute_provider_types = { - for runner_key, runner_config in local.translated_experimental_base.multi_runner_config : runner_key => one([ - for provider_type, provider_config in runner_config.compute_provider : provider_type - if provider_config != null - ]) + for runner_key, selection in local.compute_provider_selections : + runner_key => try(selection.type, null) } runner_config_by_provider = { - for provider_type in toset(values(local.compute_provider_types)) : - provider_type => { + for provider_key in toset(compact(values(local.compute_provider_keys))) : + provider_key => { for runner_key, runner_config in local.translated_experimental_base.multi_runner_config : runner_key => runner_config - if local.compute_provider_types[runner_key] == provider_type + if local.compute_provider_keys[runner_key] == provider_key } } } diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index a391be5b9a..20708f2767 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -206,87 +206,89 @@ locals { } compute_provider = { - ec2 = { - vpc_id = var.vpc_id - subnet_ids = var.subnet_ids - managed_security_group_enabled = var.enable_managed_runner_security_group - egress_rules = var.runner_egress_rules - additional_security_group_ids = var.runner_additional_security_group_ids - cloudwatch_agent = { - config = var.cloudwatch_config - } - instance_profile_path = var.instance_profile_path - key_name = var.key_name - associate_public_ipv4_address = var.associate_public_ipv4_address - tags = {} - ami = { - housekeeper = { - enabled = var.enable_ami_housekeeper - cleanup_config = var.ami_housekeeper_cleanup_config - artifact = { - zip = var.lambda_s3_bucket == null ? var.ami_housekeeper_lambda_zip : null - s3 = var.lambda_s3_bucket == null ? null : { - key = var.ami_housekeeper_lambda_s3_key - object_version = var.ami_housekeeper_lambda_s3_object_version - } - } - lambda = { - memory_size = var.ami_housekeeper_lambda_memory_size - timeout = var.ami_housekeeper_lambda_timeout - } - schedule = { - expression = var.ami_housekeeper_lambda_schedule_expression - } + aws = { + ec2 = { + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + managed_security_group_enabled = var.enable_managed_runner_security_group + egress_rules = var.runner_egress_rules + additional_security_group_ids = var.runner_additional_security_group_ids + cloudwatch_agent = { + config = var.cloudwatch_config } - } - instance_termination_watcher = { - enabled = var.instance_termination_watcher.enable - features = var.instance_termination_watcher.features - enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration - environment_variables = var.instance_termination_watcher.environment_variables - artifact = { - zip = var.lambda_s3_bucket == null ? var.instance_termination_watcher.zip : null - s3 = var.lambda_s3_bucket == null ? null : { - key = var.instance_termination_watcher.s3_key - object_version = var.instance_termination_watcher.s3_object_version - } - } - lambda = { - memory_size = var.instance_termination_watcher.memory_size - timeout = var.instance_termination_watcher.timeout - } - } - runner_binaries = { - enabled = true - s3 = { - encryption = { - enabled = var.runner_binaries_s3_sse_configuration != null - bucket_key_enabled = try(var.runner_binaries_s3_sse_configuration.rule.bucket_key_enabled, null) - sse_algorithm = try(var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm, "AES256") - kms_master_key_id = try(var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.kms_master_key_id, null) - } - tags = var.runner_binaries_s3_tags - versioning = var.runner_binaries_s3_versioning - logging = { - bucket = null - prefix = null + instance_profile_path = var.instance_profile_path + key_name = var.key_name + associate_public_ipv4_address = var.associate_public_ipv4_address + tags = {} + ami = { + housekeeper = { + enabled = var.enable_ami_housekeeper + cleanup_config = var.ami_housekeeper_cleanup_config + artifact = { + zip = var.lambda_s3_bucket == null ? var.ami_housekeeper_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.ami_housekeeper_lambda_s3_key + object_version = var.ami_housekeeper_lambda_s3_object_version + } + } + lambda = { + memory_size = var.ami_housekeeper_lambda_memory_size + timeout = var.ami_housekeeper_lambda_timeout + } + schedule = { + expression = var.ami_housekeeper_lambda_schedule_expression + } } } - syncer = { + instance_termination_watcher = { + enabled = var.instance_termination_watcher.enable + features = var.instance_termination_watcher.features + enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration + environment_variables = var.instance_termination_watcher.environment_variables artifact = { - zip = var.lambda_s3_bucket == null ? var.runner_binaries_syncer_lambda_zip : null + zip = var.lambda_s3_bucket == null ? var.instance_termination_watcher.zip : null s3 = var.lambda_s3_bucket == null ? null : { - key = var.syncer_lambda_s3_key - object_version = var.syncer_lambda_s3_object_version + key = var.instance_termination_watcher.s3_key + object_version = var.instance_termination_watcher.s3_object_version } } lambda = { - memory_size = var.runner_binaries_syncer_memory_size - timeout = var.runner_binaries_syncer_lambda_timeout + memory_size = var.instance_termination_watcher.memory_size + timeout = var.instance_termination_watcher.timeout } - schedule = { - expression = "cron(27 * * * ? *)" - state = var.state_event_rule_binaries_syncer + } + runner_binaries = { + enabled = true + s3 = { + encryption = { + enabled = var.runner_binaries_s3_sse_configuration != null + bucket_key_enabled = try(var.runner_binaries_s3_sse_configuration.rule.bucket_key_enabled, null) + sse_algorithm = try(var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm, "AES256") + kms_master_key_id = try(var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.kms_master_key_id, null) + } + tags = var.runner_binaries_s3_tags + versioning = var.runner_binaries_s3_versioning + logging = { + bucket = null + prefix = null + } + } + syncer = { + artifact = { + zip = var.lambda_s3_bucket == null ? var.runner_binaries_syncer_lambda_zip : null + s3 = var.lambda_s3_bucket == null ? null : { + key = var.syncer_lambda_s3_key + object_version = var.syncer_lambda_s3_object_version + } + } + lambda = { + memory_size = var.runner_binaries_syncer_memory_size + timeout = var.runner_binaries_syncer_lambda_timeout + } + schedule = { + expression = "cron(27 * * * ? *)" + state = var.state_event_rule_binaries_syncer + } } } } @@ -463,68 +465,70 @@ locals { } compute_provider = { - ec2 = { - metadata_options = { - instance_metadata_tags = tostring(v.runner_config.runner_metadata_options["instance_metadata_tags"]) - http_endpoint = tostring(v.runner_config.runner_metadata_options["http_endpoint"]) - http_tokens = tostring(v.runner_config.runner_metadata_options["http_tokens"]) - http_put_response_hop_limit = tonumber(v.runner_config.runner_metadata_options["http_put_response_hop_limit"]) - } - 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 + aws = { + ec2 = { + metadata_options = { + instance_metadata_tags = tostring(v.runner_config.runner_metadata_options["instance_metadata_tags"]) + http_endpoint = tostring(v.runner_config.runner_metadata_options["http_endpoint"]) + http_tokens = tostring(v.runner_config.runner_metadata_options["http_tokens"]) + http_put_response_hop_limit = tonumber(v.runner_config.runner_metadata_options["http_put_response_hop_limit"]) } - kms_key = v.runner_config.ami.kms_key_arn == null ? null : { - arn = v.runner_config.ami.kms_key_arn + 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 + 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 = length(v.runner_config.runner_additional_security_group_ids) == 0 ? null : v.runner_config.runner_additional_security_group_ids + managed_security_group_enabled = null + egress_rules = null + instance_profile_path = null + key_name = null + associate_public_ipv4_address = null + instance_profile = v.runner_config.iam_overrides.override_instance_profile == true ? { + name = v.runner_config.iam_overrides.instance_profile_name + } : null + 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 + cpu_options = v.runner_config.cpu_options + placement = v.runner_config.placement + license_specifications = v.runner_config.license_specifications + use_dedicated_host = v.runner_config.use_dedicated_host + log_files = v.runner_config.runner_log_files + tags = v.runner_config.runner_ec2_tags } - 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 = length(v.runner_config.runner_additional_security_group_ids) == 0 ? null : v.runner_config.runner_additional_security_group_ids - managed_security_group_enabled = null - egress_rules = null - instance_profile_path = null - key_name = null - associate_public_ipv4_address = null - instance_profile = v.runner_config.iam_overrides.override_instance_profile == true ? { - name = v.runner_config.iam_overrides.instance_profile_name - } : null - 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 - cpu_options = v.runner_config.cpu_options - placement = v.runner_config.placement - license_specifications = v.runner_config.license_specifications - use_dedicated_host = v.runner_config.use_dedicated_host - log_files = v.runner_config.runner_log_files - tags = v.runner_config.runner_ec2_tags } } } @@ -740,23 +744,25 @@ locals { } compute_provider = { - ec2 = v.compute_provider.ec2 == null ? null : merge(v.compute_provider.ec2, { - vpc_id = try(coalesce(v.compute_provider.ec2.vpc_id, local.raw_translated_experimental.compute_provider.ec2.vpc_id), null) - subnet_ids = v.compute_provider.ec2.subnet_ids != null ? v.compute_provider.ec2.subnet_ids : local.raw_translated_experimental.compute_provider.ec2.subnet_ids - managed_security_group_enabled = coalesce(v.compute_provider.ec2.managed_security_group_enabled, local.raw_translated_experimental.compute_provider.ec2.managed_security_group_enabled) - egress_rules = v.compute_provider.ec2.egress_rules != null ? v.compute_provider.ec2.egress_rules : local.raw_translated_experimental.compute_provider.ec2.egress_rules - additional_security_group_ids = v.compute_provider.ec2.additional_security_group_ids != null ? v.compute_provider.ec2.additional_security_group_ids : local.raw_translated_experimental.compute_provider.ec2.additional_security_group_ids - instance_profile_path = try(coalesce(v.compute_provider.ec2.instance_profile_path, local.raw_translated_experimental.compute_provider.ec2.instance_profile_path), null) - key_name = try(coalesce(v.compute_provider.ec2.key_name, local.raw_translated_experimental.compute_provider.ec2.key_name), null) - associate_public_ipv4_address = coalesce(v.compute_provider.ec2.associate_public_ipv4_address, local.raw_translated_experimental.compute_provider.ec2.associate_public_ipv4_address) - cloudwatch_agent = merge(v.compute_provider.ec2.cloudwatch_agent, { - config = try(coalesce(v.compute_provider.ec2.cloudwatch_agent.config, local.raw_translated_experimental.compute_provider.ec2.cloudwatch_agent.config), null) + aws = { + ec2 = v.compute_provider.aws.ec2 == null ? null : merge(v.compute_provider.aws.ec2, { + vpc_id = try(coalesce(v.compute_provider.aws.ec2.vpc_id, local.raw_translated_experimental.compute_provider.aws.ec2.vpc_id), null) + subnet_ids = v.compute_provider.aws.ec2.subnet_ids != null ? v.compute_provider.aws.ec2.subnet_ids : local.raw_translated_experimental.compute_provider.aws.ec2.subnet_ids + managed_security_group_enabled = coalesce(v.compute_provider.aws.ec2.managed_security_group_enabled, local.raw_translated_experimental.compute_provider.aws.ec2.managed_security_group_enabled) + egress_rules = v.compute_provider.aws.ec2.egress_rules != null ? v.compute_provider.aws.ec2.egress_rules : local.raw_translated_experimental.compute_provider.aws.ec2.egress_rules + additional_security_group_ids = v.compute_provider.aws.ec2.additional_security_group_ids != null ? v.compute_provider.aws.ec2.additional_security_group_ids : local.raw_translated_experimental.compute_provider.aws.ec2.additional_security_group_ids + instance_profile_path = try(coalesce(v.compute_provider.aws.ec2.instance_profile_path, local.raw_translated_experimental.compute_provider.aws.ec2.instance_profile_path), null) + key_name = try(coalesce(v.compute_provider.aws.ec2.key_name, local.raw_translated_experimental.compute_provider.aws.ec2.key_name), null) + associate_public_ipv4_address = coalesce(v.compute_provider.aws.ec2.associate_public_ipv4_address, local.raw_translated_experimental.compute_provider.aws.ec2.associate_public_ipv4_address) + cloudwatch_agent = merge(v.compute_provider.aws.ec2.cloudwatch_agent, { + config = try(coalesce(v.compute_provider.aws.ec2.cloudwatch_agent.config, local.raw_translated_experimental.compute_provider.aws.ec2.cloudwatch_agent.config), null) + }) + binaries_syncer = { + enabled = coalesce(v.compute_provider.aws.ec2.binaries_syncer.enabled, local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.enabled) + } + tags = merge(local.raw_translated_experimental.compute_provider.aws.ec2.tags, v.compute_provider.aws.ec2.tags) }) - binaries_syncer = { - enabled = coalesce(v.compute_provider.ec2.binaries_syncer.enabled, local.raw_translated_experimental.compute_provider.ec2.runner_binaries.enabled) - } - tags = merge(local.raw_translated_experimental.compute_provider.ec2.tags, v.compute_provider.ec2.tags) - }) + } } }) } @@ -806,11 +812,13 @@ locals { }) compute_provider = merge(v.compute_provider, { - ec2 = v.compute_provider.ec2 == null ? null : merge(v.compute_provider.ec2, { - binaries_syncer = merge(v.compute_provider.ec2.binaries_syncer, { - s3 = v.compute_provider.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map[ - "${v.runner.os}_${v.runner.architecture}" - ] : null + aws = merge(v.compute_provider.aws, { + ec2 = v.compute_provider.aws.ec2 == null ? null : merge(v.compute_provider.aws.ec2, { + binaries_syncer = merge(v.compute_provider.aws.ec2.binaries_syncer, { + s3 = v.compute_provider.aws.ec2.binaries_syncer.enabled ? local.runner_binaries_by_os_and_arch_map[ + "${v.runner.os}_${v.runner.architecture}" + ] : null + }) }) }) }) diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 8d92647278..bf57b138ba 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -20,11 +20,11 @@ locals { # Keep a concrete map type when unrelated configuration values are unknown until apply. tmp_distinct_list_unique_os_and_arch = distinct([ - for _, config in lookup(local.runner_config_by_provider, "ec2", {}) : { + for _, config in lookup(local.runner_config_by_provider, "aws_ec2", {}) : { "os_type" : config.runner.os, "architecture" : config.runner.architecture } - if config.compute_provider.ec2.binaries_syncer.enabled + if config.compute_provider.aws.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/outputs.tf b/modules/multi-runner/outputs.tf index 212988dc72..15aeaf172f 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -23,7 +23,7 @@ output "runners_map" { } output "runners_map_v2" { - description = "Experimental v2 runner resources keyed by runner configuration. The `orchestration_provider` object is canonical; `scale_up`, `scale_down`, and `pool` remain compatibility aliases." + description = "Experimental v2 runner resources keyed by runner configuration. Compute resources are grouped under `provider..`, currently `provider.aws.ec2`. The `orchestration_provider` object is canonical; `scale_up`, `scale_down`, and `pool` remain compatibility aliases." value = { for runner_key, runner in module.runner_configs : runner_key => { runner = runner.runner orchestration_provider = runner.orchestration_provider @@ -81,7 +81,7 @@ output "ssm_parameters" { } output "instance_termination_watcher" { - value = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher ? { + value = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher ? { lambda = module.instance_termination_watcher[0].spot_termination_notification.lambda lambda_log_group = module.instance_termination_watcher[0].spot_termination_notification.lambda_log_group lambda_role = module.instance_termination_watcher[0].spot_termination_notification.lambda_role @@ -89,7 +89,7 @@ output "instance_termination_watcher" { } output "instance_termination_handler" { - value = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler ? { + value = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enabled && local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_handler ? { lambda = module.instance_termination_watcher[0].spot_termination_handler.lambda lambda_log_group = module.instance_termination_watcher[0].spot_termination_handler.lambda_log_group lambda_role = module.instance_termination_watcher[0].spot_termination_handler.lambda_role diff --git a/modules/multi-runner/runner-binaries.tf b/modules/multi-runner/runner-binaries.tf index fe6534a7e5..3e7fe4462f 100644 --- a/modules/multi-runner/runner-binaries.tf +++ b/modules/multi-runner/runner-binaries.tf @@ -10,35 +10,35 @@ module "runner_binaries" { runner_os = each.value.os_type runner_architecture = each.value.architecture - lambda_s3_bucket = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.s3 == null ? null : local.translated_experimental_base.lambda.artifact.s3.bucket - syncer_lambda_s3_key = try(local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key, null) - syncer_lambda_s3_object_version = try(local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version, null) + lambda_s3_bucket = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3 == null ? null : local.translated_experimental_base.lambda.artifact.s3.bucket + syncer_lambda_s3_key = try(local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key, null) + syncer_lambda_s3_object_version = try(local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version, null) lambda_runtime = local.translated_experimental_base.lambda.runtime lambda_architecture = local.translated_experimental_base.lambda.architecture - lambda_zip = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.artifact.zip - lambda_memory_size = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size - lambda_timeout = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.lambda.timeout + lambda_zip = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip + lambda_memory_size = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size + lambda_timeout = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout lambda_tags = local.translated_experimental_base.lambda.tags tracing_config = local.translated_experimental_base.observability.tracing logging_retention_in_days = local.translated_experimental_base.observability.logs.retention_in_days logging_kms_key_id = local.translated_experimental_base.observability.logs.kms_key_id log_class = local.translated_experimental_base.observability.logs.class - state_event_rule_binaries_syncer = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.schedule.state - lambda_schedule_expression = local.translated_experimental_base.compute_provider.ec2.runner_binaries.syncer.schedule.expression + state_event_rule_binaries_syncer = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.schedule.state + lambda_schedule_expression = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression - server_side_encryption_configuration = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.enabled ? { + server_side_encryption_configuration = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled ? { rule = { - bucket_key_enabled = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled + bucket_key_enabled = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.encryption.bucket_key_enabled apply_server_side_encryption_by_default = { - sse_algorithm = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm - kms_master_key_id = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id + sse_algorithm = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm + kms_master_key_id = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id } } } : null - s3_tags = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.tags - s3_versioning = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.versioning - s3_logging_bucket = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.logging.bucket - s3_logging_bucket_prefix = local.translated_experimental_base.compute_provider.ec2.runner_binaries.s3.logging.prefix + s3_tags = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.tags + s3_versioning = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.versioning + s3_logging_bucket = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.logging.bucket + s3_logging_bucket_prefix = local.translated_experimental_base.compute_provider.aws.ec2.runner_binaries.s3.logging.prefix role_path = try(coalesce(local.translated_experimental_base.lambda.role.path, local.translated_experimental_base.roles.path), null) role_permissions_boundary = try(coalesce(local.translated_experimental_base.lambda.role.permissions_boundary, local.translated_experimental_base.roles.permissions_boundary), null) diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 5b5898b8e2..ec624d33e0 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -7,31 +7,31 @@ module "runners" { aws_region = var.aws_region aws_partition = var.aws_partition - vpc_id = each.value.compute_provider.ec2.vpc_id - subnet_ids = each.value.compute_provider.ec2.subnet_ids + vpc_id = each.value.compute_provider.aws.ec2.vpc_id + subnet_ids = each.value.compute_provider.aws.ec2.subnet_ids prefix = "${var.prefix}-${each.key}" tags = merge(each.value.tags, { "ghr:environment" = "${var.prefix}-${each.key}" }) - s3_runner_binaries = each.value.compute_provider.ec2.binaries_syncer.s3 + s3_runner_binaries = each.value.compute_provider.aws.ec2.binaries_syncer.s3 ssm_paths = each.value.ssm.paths 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 + instance_types = each.value.compute_provider.aws.ec2.instance_types + instance_target_capacity_type = each.value.compute_provider.aws.ec2.instance_target_capacity_type + instance_allocation_strategy = each.value.compute_provider.aws.ec2.instance_allocation_strategy + instance_type_priorities = each.value.compute_provider.aws.ec2.instance_type_priorities + instance_max_spot_price = each.value.compute_provider.aws.ec2.instance_max_spot_price + block_device_mappings = each.value.compute_provider.aws.ec2.block_device_mappings runner_architecture = each.value.runner.architecture - ami = each.value.compute_provider.ec2.ami == null ? null : { - filter = each.value.compute_provider.ec2.ami.filter - owners = each.value.compute_provider.ec2.ami.owners - id_ssm_parameter_arn = try(each.value.compute_provider.ec2.ami.id_ssm_parameter.arn, null) - kms_key_arn = try(each.value.compute_provider.ec2.ami.kms_key.arn, null) + ami = each.value.compute_provider.aws.ec2.ami == null ? null : { + filter = each.value.compute_provider.aws.ec2.ami.filter + owners = each.value.compute_provider.aws.ec2.ami.owners + id_ssm_parameter_arn = try(each.value.compute_provider.aws.ec2.ami.id_ssm_parameter.arn, null) + kms_key_arn = try(each.value.compute_provider.aws.ec2.ami.kms_key.arn, null) } sqs_build_queue = { @@ -39,16 +39,16 @@ module "runners" { url = aws_sqs_queue.queued_builds[each.key].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 + ebs_optimized = each.value.compute_provider.aws.ec2.ebs_optimized + enable_on_demand_failover_for_errors = each.value.compute_provider.aws.ec2.enable_on_demand_failover_for_errors + scale_errors = each.value.compute_provider.aws.ec2.scale_errors enable_organization_runners = each.value.orchestration_provider.webhook.github.organization_runners enable_ephemeral_runners = each.value.orchestration_provider.webhook.runner.ephemeral enable_jit_config = each.value.orchestration_provider.webhook.runner.jit_config_enabled enable_job_queued_check = each.value.orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled disable_runner_autoupdate = each.value.runner.auto_update_disabled - enable_managed_runner_security_group = each.value.compute_provider.ec2.managed_security_group_enabled - enable_runner_detailed_monitoring = each.value.compute_provider.ec2.detailed_monitoring_enabled + enable_managed_runner_security_group = each.value.compute_provider.aws.ec2.managed_security_group_enabled + enable_runner_detailed_monitoring = each.value.compute_provider.aws.ec2.detailed_monitoring_enabled scale_down_schedule_expression = each.value.orchestration_provider.webhook.lambda.scale.down.schedule_expression minimum_running_time_in_minutes = each.value.orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes runner_boot_time_in_minutes = each.value.orchestration_provider.webhook.runner.boot_time_in_minutes @@ -58,17 +58,17 @@ module "runners" { runner_run_as = each.value.runner.run_as runners_maximum_count = each.value.orchestration_provider.webhook.runner.maximum_count idle_config = each.value.orchestration_provider.webhook.lambda.scale.down.idle_config - enable_ssm_on_runners = each.value.compute_provider.ec2.ssm_enabled - egress_rules = each.value.compute_provider.ec2.egress_rules - runner_additional_security_group_ids = each.value.compute_provider.ec2.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 + enable_ssm_on_runners = each.value.compute_provider.aws.ec2.ssm_enabled + egress_rules = each.value.compute_provider.aws.ec2.egress_rules + runner_additional_security_group_ids = each.value.compute_provider.aws.ec2.additional_security_group_ids + metadata_options = each.value.compute_provider.aws.ec2.metadata_options + credit_specification = each.value.compute_provider.aws.ec2.credit_specification + cpu_options = each.value.compute_provider.aws.ec2.cpu_options + placement = each.value.compute_provider.aws.ec2.placement + license_specifications = each.value.compute_provider.aws.ec2.license_specifications + use_dedicated_host = each.value.compute_provider.aws.ec2.use_dedicated_host + + enable_runner_binaries_syncer = each.value.compute_provider.aws.ec2.binaries_syncer.enabled lambda_s3_bucket = local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket runners_lambda_s3_key = try(local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.key, null) runners_lambda_s3_object_version = try(local.translated_experimental.orchestration_provider.webhook.lambda.artifact.s3.object_version, null) @@ -88,36 +88,36 @@ module "runners" { logging_retention_in_days = each.value.observability.logs.retention_in_days logging_kms_key_id = each.value.observability.logs.kms_key_id log_class = each.value.observability.logs.class - enable_cloudwatch_agent = each.value.compute_provider.ec2.cloudwatch_agent.enabled - cloudwatch_config = each.value.compute_provider.ec2.cloudwatch_agent.config - runner_log_files = each.value.compute_provider.ec2.log_files + enable_cloudwatch_agent = each.value.compute_provider.aws.ec2.cloudwatch_agent.enabled + cloudwatch_config = each.value.compute_provider.aws.ec2.cloudwatch_agent.config + runner_log_files = each.value.compute_provider.aws.ec2.log_files runner_group_name = each.value.runner.group_name runner_name_prefix = each.value.runner.name_prefix parameter_store_tags = each.value.ssm.parameters.tags scale_up_reserved_concurrent_executions = each.value.orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions - instance_profile_path = each.value.compute_provider.ec2.instance_profile_path + instance_profile_path = each.value.compute_provider.aws.ec2.instance_profile_path role_path = each.value.runner.iam.path role_permissions_boundary = each.value.runner.iam.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 - enable_user_data_debug_logging = each.value.compute_provider.ec2.user_data.debug_logging_enabled + enable_userdata = each.value.compute_provider.aws.ec2.user_data.enabled + userdata_template = each.value.compute_provider.aws.ec2.user_data.template + userdata_content = each.value.compute_provider.aws.ec2.user_data.content + userdata_pre_install = each.value.compute_provider.aws.ec2.user_data.pre_install + userdata_post_install = each.value.compute_provider.aws.ec2.user_data.post_install + enable_user_data_debug_logging = each.value.compute_provider.aws.ec2.user_data.debug_logging_enabled runner_hook_job_started = each.value.runner.hooks.job_started runner_hook_job_completed = each.value.runner.hooks.job_completed - key_name = each.value.compute_provider.ec2.key_name - runner_ec2_tags = each.value.compute_provider.ec2.tags + key_name = each.value.compute_provider.aws.ec2.key_name + runner_ec2_tags = each.value.compute_provider.aws.ec2.tags - create_service_linked_role_spot = each.value.compute_provider.ec2.create_service_linked_role_spot + create_service_linked_role_spot = each.value.compute_provider.aws.ec2.create_service_linked_role_spot runner_iam_role_managed_policy_arns = values(each.value.runner.iam.managed_policy_arns) iam_overrides = { - override_instance_profile = each.value.compute_provider.ec2.instance_profile != null - instance_profile_name = try(each.value.compute_provider.ec2.instance_profile.name, null) + override_instance_profile = each.value.compute_provider.aws.ec2.instance_profile != null + instance_profile_name = try(each.value.compute_provider.aws.ec2.instance_profile.name, null) override_runner_role = each.value.runner.iam.role != null runner_role_arn = try(each.value.runner.iam.role.arn, null) } @@ -136,7 +136,7 @@ module "runners" { pool_runner_owner = each.value.orchestration_provider.webhook.lambda.pool.runner_owner pool_include_busy_runners = each.value.orchestration_provider.webhook.lambda.pool.include_busy_runners pool_lambda_reserved_concurrent_executions = each.value.orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions - associate_public_ipv4_address = each.value.compute_provider.ec2.associate_public_ipv4_address + associate_public_ipv4_address = each.value.compute_provider.aws.ec2.associate_public_ipv4_address ssm_housekeeper = { schedule_expression = each.value.ssm.housekeeper.schedule_expression diff --git a/modules/multi-runner/termination-watcher.tf b/modules/multi-runner/termination-watcher.tf index 4fd3227a40..38d50804c1 100644 --- a/modules/multi-runner/termination-watcher.tf +++ b/modules/multi-runner/termination-watcher.tf @@ -1,6 +1,6 @@ module "instance_termination_watcher" { source = "../termination-watcher" - count = try(local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled, false) ? 1 : 0 + count = try(local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enabled, false) ? 1 : 0 config = { prefix = var.prefix @@ -17,22 +17,22 @@ module "instance_termination_watcher" { logging_retention_in_days = local.translated_experimental.observability.logs.retention_in_days role_path = try(coalesce(local.translated_experimental.lambda.role.path, local.translated_experimental.roles.path), null) role_permissions_boundary = try(coalesce(local.translated_experimental.lambda.role.permissions_boundary, local.translated_experimental.roles.permissions_boundary), null) - s3_bucket = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket + s3_bucket = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3 == null ? null : local.translated_experimental.lambda.artifact.s3.bucket tracing_config = local.translated_experimental.observability.tracing lambda_tags = local.translated_experimental.lambda.tags metrics = local.translated_experimental.observability.metrics - features = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.features - memory_size = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.memory_size - timeout = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.timeout - zip = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip - s3_key = try(local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key, null) - s3_object_version = try(local.translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version, null) - enable_runner_deregistration = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration - github_app_parameters = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration ? { + features = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.features + memory_size = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size + timeout = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout + zip = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.zip + s3_key = try(local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key, null) + s3_object_version = try(local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version, null) + enable_runner_deregistration = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration + github_app_parameters = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration ? { id = local.github_app_parameters.id[0] key_base64 = local.github_app_parameters.key_base64[0] } : null ghes_url = local.translated_experimental.github.enterprise_server.url - environment_variables = local.translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables + environment_variables = local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.environment_variables } } diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf index 887731e3ba..df3d9f5ac0 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -9,16 +9,8 @@ module "multi_runner" { aws_region = "eu-west-1" prefix = "computed-inputs" - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/generated-${random_id.managed_policy.hex}" - 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" @@ -70,9 +62,11 @@ module "multi_runner" { } compute_provider = { - ec2 = { - vpc_id = "vpc-nested-12345678" - subnet_ids = ["subnet-nested-12345678"] + aws = { + ec2 = { + vpc_id = "vpc-nested-12345678" + subnet_ids = ["subnet-nested-12345678"] + } } } @@ -112,10 +106,12 @@ module "multi_runner" { } } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } diff --git a/modules/multi-runner/tests/provider-routing-v1.tftest.hcl b/modules/multi-runner/tests/provider-routing-v1.tftest.hcl index ef0361795f..ebe79fb8aa 100644 --- a/modules/multi-runner/tests/provider-routing-v1.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing-v1.tftest.hcl @@ -422,8 +422,9 @@ run "stable_v1_keeps_legacy_runner_module" { "tags", "encryption", ]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["ec2"]) - && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["aws"]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.aws)) == toset(["ec2"]) + && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled"]) && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].ssm), "kms_key_id") && !contains(keys(local.raw_translated_experimental.runner), "boot_time_in_minutes") && !contains(keys(local.raw_translated_experimental.runner), "ephemeral") @@ -441,11 +442,11 @@ run "stable_v1_keeps_legacy_runner_module" { assert { condition = ( - toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) - && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null - && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3)) == toset(["arn", "id", "key"]) + toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer.enabled + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer.s3 != null + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer.s3)) == toset(["arn", "id", "key"]) ) error_message = "Stable translation must discover binary-syncer runner configurations from the base object, then enrich the final canonical configuration with its resolved S3 distribution." } @@ -507,35 +508,35 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.key == var.runners_lambda_s3_key && local.translated_experimental.multi_runner_config["linux"].ssm.housekeeper.lambda.artifact.s3.object_version == var.runners_lambda_s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.vpc_id == var.vpc_id - && local.raw_translated_experimental.compute_provider.ec2.subnet_ids == var.subnet_ids - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.enabled == var.enable_ami_housekeeper - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.cleanup_config == var.ami_housekeeper_cleanup_config - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.zip == null - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key == var.ami_housekeeper_lambda_s3_key - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.object_version == var.ami_housekeeper_lambda_s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.memory_size == var.ami_housekeeper_lambda_memory_size - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.lambda.timeout == var.ami_housekeeper_lambda_timeout - && local.raw_translated_experimental.compute_provider.ec2.ami.housekeeper.schedule.expression == var.ami_housekeeper_lambda_schedule_expression - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled == var.instance_termination_watcher.enable - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.features == var.instance_termination_watcher.features - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration == var.instance_termination_watcher.enable_runner_deregistration - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables == var.instance_termination_watcher.environment_variables - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip == null - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key == var.instance_termination_watcher.s3_key - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version == var.instance_termination_watcher.s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.memory_size == var.instance_termination_watcher.memory_size - && local.raw_translated_experimental.compute_provider.ec2.instance_termination_watcher.lambda.timeout == var.instance_termination_watcher.timeout - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.enabled - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm == var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.tags == var.runner_binaries_s3_tags - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == var.runner_binaries_s3_versioning - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key == var.syncer_lambda_s3_key - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version == var.syncer_lambda_s3_object_version - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size == var.runner_binaries_syncer_memory_size - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.timeout == var.runner_binaries_syncer_lambda_timeout - && local.raw_translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == var.state_event_rule_binaries_syncer + && local.raw_translated_experimental.compute_provider.aws.ec2.vpc_id == var.vpc_id + && local.raw_translated_experimental.compute_provider.aws.ec2.subnet_ids == var.subnet_ids + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.enabled == var.enable_ami_housekeeper + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.cleanup_config == var.ami_housekeeper_cleanup_config + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.zip == null + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key == var.ami_housekeeper_lambda_s3_key + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version == var.ami_housekeeper_lambda_s3_object_version + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size == var.ami_housekeeper_lambda_memory_size + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.lambda.timeout == var.ami_housekeeper_lambda_timeout + && local.raw_translated_experimental.compute_provider.aws.ec2.ami.housekeeper.schedule.expression == var.ami_housekeeper_lambda_schedule_expression + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enabled == var.instance_termination_watcher.enable + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.features == var.instance_termination_watcher.features + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration == var.instance_termination_watcher.enable_runner_deregistration + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.environment_variables == var.instance_termination_watcher.environment_variables + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.zip == null + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key == var.instance_termination_watcher.s3_key + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version == var.instance_termination_watcher.s3_object_version + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size == var.instance_termination_watcher.memory_size + && local.raw_translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout == var.instance_termination_watcher.timeout + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.enabled + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm == var.runner_binaries_s3_sse_configuration.rule.apply_server_side_encryption_by_default.sse_algorithm + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.tags == var.runner_binaries_s3_tags + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.versioning == var.runner_binaries_s3_versioning + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key == var.syncer_lambda_s3_key + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version == var.syncer_lambda_s3_object_version + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size == var.runner_binaries_syncer_memory_size + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout == var.runner_binaries_syncer_lambda_timeout + && local.raw_translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.state == var.state_event_rule_binaries_syncer && local.raw_translated_experimental.multi_runner_config["linux"].runner.os == var.multi_runner_config["linux"].runner_config.runner_os && local.raw_translated_experimental.multi_runner_config["linux"].runner.architecture == var.multi_runner_config["linux"].runner_config.runner_architecture && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].runner), "boot_time_in_minutes") @@ -551,7 +552,7 @@ run "stable_v1_keeps_legacy_runner_module" { && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.job_queue_retention_in_seconds == var.multi_runner_config["linux"].runner_config.job_queue_retention_in_seconds && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.visibility_timeout_seconds == var.runners_scale_up_lambda_timeout && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.queue.redrive_build_queue == var.multi_runner_config["linux"].redrive_build_queue - && local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.enabled + && local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer.enabled && local.raw_translated_experimental.multi_runner_config["linux"].orchestration_provider.webhook.matcherConfig == var.multi_runner_config["linux"].matcherConfig ) error_message = "Stable v1 flat and per-runner inputs must populate every raw translation family while conflicting experimental globals remain inactive." @@ -567,7 +568,7 @@ run "stable_v1_keeps_legacy_runner_module" { } assert { - condition = keys(local.runner_config_by_provider.ec2) == ["linux"] + condition = keys(local.runner_config_by_provider.aws_ec2) == ["linux"] error_message = "Stable multi_runner_config entries must route to the EC2 provider." } diff --git a/modules/multi-runner/tests/provider-routing-v2.tftest.hcl b/modules/multi-runner/tests/provider-routing-v2.tftest.hcl index 64684be753..531d32ee12 100644 --- a/modules/multi-runner/tests/provider-routing-v2.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing-v2.tftest.hcl @@ -37,6 +37,10 @@ run "experimental_v2_routes_through_provider_stack" { command = plan variables { + github_app = null + vpc_id = null + subnet_ids = null + additional_github_apps = [{ id_ssm = { name = "/github-runner/additional-app-id" @@ -251,13 +255,15 @@ run "experimental_v2_routes_through_provider_stack" { } compute_provider = { - ec2 = { - vpc_id = "vpc-experimental-defaults" - subnet_ids = ["subnet-experimental-defaults"] - runner_binaries = { - syncer = { - artifact = { - zip = "README.md" + aws = { + ec2 = { + vpc_id = "vpc-experimental-defaults" + subnet_ids = ["subnet-experimental-defaults"] + runner_binaries = { + syncer = { + artifact = { + zip = "README.md" + } } } } @@ -328,10 +334,12 @@ run "experimental_v2_routes_through_provider_stack" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = true + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = true + } } } } @@ -349,7 +357,7 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = keys(local.runner_config_by_provider.ec2) == ["linux"] + condition = keys(local.runner_config_by_provider.aws_ec2) == ["linux"] error_message = "Experimental multi_runner_config entries must route to the EC2 provider." } @@ -368,9 +376,9 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) - && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.binaries_syncer.s3 != null + toset(keys(local.translated_experimental_base.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer.s3 != null && keys(output.binaries_syncer_map) == ["linux_x64"] ) error_message = "V2 binary discovery must use the pure base runner configuration, enrich the final canonical configuration with S3, and create the corresponding shared syncer resources." @@ -652,19 +660,19 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - local.translated_experimental.compute_provider.ec2.runner_binaries.enabled - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm == "AES256" - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id == null - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == "Disabled" - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.logging.bucket == null - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.logging.prefix == null - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.zip == "README.md" - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 == null - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.memory_size == 256 - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.lambda.timeout == 300 - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.expression == "cron(27 * * * ? *)" - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == "ENABLED" + local.translated_experimental.compute_provider.aws.ec2.runner_binaries.enabled + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm == "AES256" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id == null + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.versioning == "Disabled" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.logging.bucket == null + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.logging.prefix == null + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip == "README.md" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3 == null + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size == 256 + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout == 300 + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression == "cron(27 * * * ? *)" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.state == "ENABLED" ) error_message = "The nested v2 runner-binary block must own concrete defaults independently of matching flat syncer and bucket inputs." } @@ -698,21 +706,21 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.vpc_id == "vpc-experimental-defaults" - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.subnet_ids == tolist(["subnet-experimental-defaults"]) - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.managed_security_group_enabled - && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules) == 1 - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules[0].cidr_blocks == tolist(["0.0.0.0/0"]) - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules[0].ipv6_cidr_blocks == tolist(["::/0"]) - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.egress_rules[0].protocol == "-1" - && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.additional_security_group_ids) == 0 - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.cloudwatch_agent.config == null - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.instance_profile_path == null - && local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.key_name == null - && !local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.associate_public_ipv4_address - && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.ec2.tags) == 0 - && !local.translated_experimental.compute_provider.ec2.ami.housekeeper.enabled - && !local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled + local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.vpc_id == "vpc-experimental-defaults" + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.subnet_ids == tolist(["subnet-experimental-defaults"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.managed_security_group_enabled + && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.egress_rules) == 1 + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.egress_rules[0].cidr_blocks == tolist(["0.0.0.0/0"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.egress_rules[0].ipv6_cidr_blocks == tolist(["::/0"]) + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.egress_rules[0].protocol == "-1" + && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.additional_security_group_ids) == 0 + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.cloudwatch_agent.config == null + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.instance_profile_path == null + && local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.key_name == null + && !local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.associate_public_ipv4_address + && length(local.translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.tags) == 0 + && !local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.enabled + && !local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enabled && length(module.ami_housekeeper) == 0 && length(module.instance_termination_watcher) == 0 && output.instance_termination_watcher == null @@ -724,7 +732,9 @@ run "experimental_v2_routes_through_provider_stack" { condition = ( local.translated_experimental.github.app == var.experimental.github.app && local.translated_experimental.github.additional_apps == var.experimental.github.additional_apps - && local.translated_experimental.github.app == var.github_app + && var.github_app == null + && var.vpc_id == null + && var.subnet_ids == null && local.translated_experimental.github.additional_apps == var.additional_github_apps && length(local.github_app_parameters.id) == 2 && length(module.ssm.additional_app_parameters) == 1 @@ -733,7 +743,7 @@ run "experimental_v2_routes_through_provider_stack" { && module.runner_configs["linux"].orchestration_provider.webhook.scale_down.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == join(":", [for p in local.github_app_parameters.key_base64 : p.name]) && module.runner_configs["linux"].orchestration_provider.webhook.pool.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == join(":", [for p in local.github_app_parameters.installation_id : p != null ? p.name : ""]) ) - error_message = "Experimental v2 control-plane Lambdas must preserve the complete multi-app parameter lists from the shared multi-runner configuration." + error_message = "Experimental v2 must use nested GitHub App and network inputs while preserving the complete multi-app parameter lists for control-plane Lambdas." } assert { @@ -780,21 +790,22 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - toset(keys(output.runners_map_v2["linux"].provider)) == toset(["ec2"]) - && toset(keys(output.runners_map_v2["linux"].provider.ec2)) == toset([ + toset(keys(output.runners_map_v2["linux"].provider)) == toset(["aws"]) + && toset(keys(output.runners_map_v2["linux"].provider.aws)) == toset(["ec2"]) + && toset(keys(output.runners_map_v2["linux"].provider.aws.ec2)) == toset([ "launch_template", "runners_log_groups", "logfiles", ]) ) - error_message = "Experimental v2 must expose only EC2-owned resources under runners_map_v2..provider.ec2." + error_message = "Experimental v2 must expose only EC2-owned resources under runners_map_v2..provider.aws.ec2." } assert { condition = ( !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"].provider.aws.ec2), "role_runner") && !contains(keys(output.runners_map_v2["linux"]), "runners_log_groups") && !contains(keys(output.runners_map_v2["linux"]), "logfiles") ) @@ -802,19 +813,19 @@ run "experimental_v2_routes_through_provider_stack" { } assert { - condition = local.runner_config_by_provider.ec2["linux"].orchestration_provider.webhook.lambda.scale.down.idle_config[0].idleCount == 1 + condition = local.runner_config_by_provider.aws_ec2["linux"].orchestration_provider.webhook.lambda.scale.down.idle_config[0].idleCount == 1 error_message = "Webhook-owned idle configuration must remain in the orchestration provider input contract." } assert { - condition = local.runner_config_by_provider.ec2["linux"].runner.iam.managed_policy_arns.readonly == "arn:aws:iam::aws:policy/ReadOnlyAccess" + condition = local.runner_config_by_provider.aws_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") + local.runner_config_by_provider.aws_ec2["linux"].runner.hooks.job_started == "/opt/actions/job-started.sh" + && !contains(keys(local.runner_config_by_provider.aws_ec2["linux"].compute_provider.aws.ec2), "hooks") ) error_message = "Runner lifecycle hooks must remain in the common runner contract." } @@ -836,9 +847,11 @@ run "experimental_v2_rejects_missing_orchestration_provider" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-missing-orchestration-provider" - subnet_ids = ["subnet-missing-orchestration-provider"] + aws = { + ec2 = { + vpc_id = "vpc-missing-orchestration-provider" + subnet_ids = ["subnet-missing-orchestration-provider"] + } } } multi_runner_config = { @@ -849,8 +862,10 @@ run "experimental_v2_rejects_missing_orchestration_provider" { } orchestration_provider = {} compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -878,9 +893,11 @@ run "experimental_v2_requires_webhook_maximum_count" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-missing-webhook-maximum" - subnet_ids = ["subnet-missing-webhook-maximum"] + aws = { + ec2 = { + vpc_id = "vpc-missing-webhook-maximum" + subnet_ids = ["subnet-missing-webhook-maximum"] + } } } multi_runner_config = { @@ -897,8 +914,10 @@ run "experimental_v2_requires_webhook_maximum_count" { } } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -1092,79 +1111,81 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { } compute_provider = { - ec2 = { - vpc_id = "vpc-experimental" - subnet_ids = ["subnet-experimental"] - ami = { - housekeeper = { + aws = { + ec2 = { + vpc_id = "vpc-experimental" + subnet_ids = ["subnet-experimental"] + ami = { + housekeeper = { + enabled = true + cleanup_config = { + maxItems = 5 + minimumDaysOld = 10 + dryRun = true + } + artifact = { + s3 = { + key = "nested-ami-housekeeper.zip" + object_version = "nested-ami-housekeeper-version" + } + } + lambda = { + memory_size = 448 + timeout = 120 + } + schedule = { + expression = "rate(3 days)" + } + } + } + instance_termination_watcher = { enabled = true - cleanup_config = { - maxItems = 5 - minimumDaysOld = 10 - dryRun = true + features = { + enable_spot_termination_handler = false + enable_spot_termination_notification_watcher = true + } + enable_runner_deregistration = true + environment_variables = { + NESTED_WATCHER = "true" } artifact = { s3 = { - key = "nested-ami-housekeeper.zip" - object_version = "nested-ami-housekeeper-version" + key = "nested-termination-watcher.zip" + object_version = "nested-watcher-version" } } lambda = { - memory_size = 448 - timeout = 120 + memory_size = 432 + timeout = 41 } - schedule = { - expression = "rate(3 days)" - } - } - } - instance_termination_watcher = { - enabled = true - features = { - enable_spot_termination_handler = false - enable_spot_termination_notification_watcher = true - } - enable_runner_deregistration = true - environment_variables = { - NESTED_WATCHER = "true" } - artifact = { + runner_binaries = { + enabled = false s3 = { - key = "nested-termination-watcher.zip" - object_version = "nested-watcher-version" - } - } - lambda = { - memory_size = 432 - timeout = 41 - } - } - runner_binaries = { - enabled = false - s3 = { - tags = { - BinaryBucket = "global" - } - versioning = "Enabled" - logging = { - bucket = "runner-binaries-access-logs" - prefix = "runner-binaries/" - } - } - syncer = { - artifact = { - s3 = { - key = "nested-runner-binaries-syncer.zip" - object_version = "nested-version" + tags = { + BinaryBucket = "global" + } + versioning = "Enabled" + logging = { + bucket = "runner-binaries-access-logs" + prefix = "runner-binaries/" } } - lambda = { - memory_size = 384 - timeout = 240 - } - schedule = { - expression = "rate(2 hours)" - state = "DISABLED" + syncer = { + artifact = { + s3 = { + key = "nested-runner-binaries-syncer.zip" + object_version = "nested-version" + } + } + lambda = { + memory_size = 384 + timeout = 240 + } + schedule = { + expression = "rate(2 hours)" + state = "DISABLED" + } } } } @@ -1237,11 +1258,13 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - subnet_ids = ["subnet-lane"] - binaries_syncer = { - enabled = true + aws = { + ec2 = { + instance_types = ["m5.large"] + subnet_ids = ["subnet-lane"] + binaries_syncer = { + enabled = true + } } } } @@ -1271,12 +1294,12 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { assert { condition = ( - local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.vpc_id == "vpc-experimental" - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.subnet_ids == tolist(["subnet-lane"]) - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer.enabled - && toset(keys(local.translated_experimental_base.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) - && toset(keys(local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.binaries_syncer.s3 != null + local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.vpc_id == "vpc-experimental" + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.subnet_ids == tolist(["subnet-lane"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.binaries_syncer.enabled + && toset(keys(local.translated_experimental_base.multi_runner_config["resolved"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.binaries_syncer.s3 != null && keys(output.binaries_syncer_map) == ["linux_x64"] && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" ) @@ -1285,15 +1308,15 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { assert { condition = ( - !local.translated_experimental.compute_provider.ec2.runner_binaries.enabled - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.versioning == "Enabled" - && local.translated_experimental.compute_provider.ec2.runner_binaries.s3.logging.bucket == "runner-binaries-access-logs" + !local.translated_experimental.compute_provider.aws.ec2.runner_binaries.enabled + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.versioning == "Enabled" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.s3.logging.bucket == "runner-binaries-access-logs" && local.translated_experimental.lambda.artifact.s3.bucket == "experimental-lambda-artifacts" - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.zip == null - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key == "nested-runner-binaries-syncer.zip" - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version == "nested-version" - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.expression == "rate(2 hours)" - && local.translated_experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state == "DISABLED" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip == null + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key == "nested-runner-binaries-syncer.zip" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version == "nested-version" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression == "rate(2 hours)" + && local.translated_experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.state == "DISABLED" && keys(output.binaries_syncer_map) == ["linux_x64"] && output.binaries_syncer_map["linux_x64"].lambda.s3_bucket == "experimental-lambda-artifacts" && output.binaries_syncer_map["linux_x64"].lambda.s3_key == "nested-runner-binaries-syncer.zip" @@ -1307,16 +1330,16 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { assert { condition = ( - length(local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules) == 1 - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].cidr_blocks == tolist(["0.0.0.0/0"]) - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].ipv6_cidr_blocks == tolist(["::/0"]) - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].prefix_list_ids == null - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].from_port == 0 - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].protocol == "-1" - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].security_groups == null - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].self == null - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].to_port == 0 - && local.translated_experimental.multi_runner_config["resolved"].compute_provider.ec2.egress_rules[0].description == null + length(local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules) == 1 + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].cidr_blocks == tolist(["0.0.0.0/0"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].ipv6_cidr_blocks == tolist(["::/0"]) + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].prefix_list_ids == null + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].from_port == 0 + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].protocol == "-1" + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].security_groups == null + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].self == null + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].to_port == 0 + && local.translated_experimental.multi_runner_config["resolved"].compute_provider.aws.ec2.egress_rules[0].description == null ) error_message = "Omitted experimental EC2 egress rules must resolve to the concrete nested allow-all IPv4 and IPv6 default independently of the stable input." } @@ -1406,7 +1429,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { && module.runner_configs["resolved"].orchestration_provider.webhook.scale_down.lambda.environment[0].variables["GHES_URL"] == "https://experimental-shared.example.com" && module.runner_configs["resolved"].orchestration_provider.webhook.pool.lambda.environment[0].variables["USER_AGENT"] == "experimental-runner-user-agent" && module.runner_configs["resolved"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == module.ssm.parameters.github_app_id.name - && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.environment_variables["NESTED_WATCHER"] == "true" + && local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.environment_variables["NESTED_WATCHER"] == "true" && output.instance_termination_watcher.lambda.function.runtime == "nodejs22.x" && output.instance_termination_watcher.lambda.function.architectures == tolist(["arm64"]) && output.instance_termination_watcher.lambda.function.memory_size == 432 @@ -1429,7 +1452,7 @@ run "experimental_v2_applies_global_defaults_and_configuration_overrides" { assert { condition = ( - local.translated_experimental.compute_provider.ec2.ami.housekeeper.enabled + local.translated_experimental.compute_provider.aws.ec2.ami.housekeeper.enabled && length(module.ami_housekeeper) == 1 && module.ami_housekeeper[0].lambda.runtime == "nodejs22.x" && module.ami_housekeeper[0].lambda.architectures == tolist(["arm64"]) @@ -1643,9 +1666,11 @@ run "experimental_v2_layers_observability_and_ssm" { } compute_provider = { - ec2 = { - vpc_id = "vpc-global-observability" - subnet_ids = ["subnet-global-observability"] + aws = { + ec2 = { + vpc_id = "vpc-global-observability" + subnet_ids = ["subnet-global-observability"] + } } } @@ -1665,10 +1690,12 @@ run "experimental_v2_layers_observability_and_ssm" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -1750,10 +1777,12 @@ run "experimental_v2_layers_observability_and_ssm" { } compute_provider = { - ec2 = { - instance_types = ["c5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["c5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -1764,10 +1793,10 @@ run "experimental_v2_layers_observability_and_ssm" { assert { condition = ( - toset(keys(local.translated_experimental_base.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer)) == toset(["enabled"]) - && toset(keys(local.translated_experimental.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer)) == toset(["enabled", "s3"]) - && !local.translated_experimental.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer.enabled - && local.translated_experimental.multi_runner_config["inherited"].compute_provider.ec2.binaries_syncer.s3 == null + toset(keys(local.translated_experimental_base.multi_runner_config["inherited"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled"]) + && toset(keys(local.translated_experimental.multi_runner_config["inherited"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled", "s3"]) + && !local.translated_experimental.multi_runner_config["inherited"].compute_provider.aws.ec2.binaries_syncer.enabled + && local.translated_experimental.multi_runner_config["inherited"].compute_provider.aws.ec2.binaries_syncer.s3 == null && !contains(keys(output.binaries_syncer_map), "linux_x64") ) error_message = "A disabled binary syncer must gain a known null S3 value only in the final canonical runner configuration and create no shared syncer resources." @@ -1987,9 +2016,11 @@ run "experimental_v2_requires_global_github_app" { variables { experimental = { compute_provider = { - ec2 = { - vpc_id = "vpc-missing-github-app" - subnet_ids = ["subnet-missing-github-app"] + aws = { + ec2 = { + vpc_id = "vpc-missing-github-app" + subnet_ids = ["subnet-missing-github-app"] + } } } multi_runner_config = { @@ -2012,10 +2043,12 @@ run "experimental_v2_requires_global_github_app" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2055,9 +2088,11 @@ run "experimental_v2_rejects_incomplete_global_github_app" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-incomplete-github-app" - subnet_ids = ["subnet-incomplete-github-app"] + aws = { + ec2 = { + vpc_id = "vpc-incomplete-github-app" + subnet_ids = ["subnet-incomplete-github-app"] + } } } multi_runner_config = { @@ -2080,10 +2115,12 @@ run "experimental_v2_rejects_incomplete_global_github_app" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2127,9 +2164,11 @@ run "experimental_v2_rejects_incomplete_additional_github_app" { }] } compute_provider = { - ec2 = { - vpc_id = "vpc-incomplete-additional-app" - subnet_ids = ["subnet-incomplete-additional-app"] + aws = { + ec2 = { + vpc_id = "vpc-incomplete-additional-app" + subnet_ids = ["subnet-incomplete-additional-app"] + } } } multi_runner_config = { @@ -2152,10 +2191,12 @@ run "experimental_v2_rejects_incomplete_additional_github_app" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2195,9 +2236,11 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-mismatched-primary-app" - subnet_ids = ["subnet-mismatched-primary-app"] + aws = { + ec2 = { + vpc_id = "vpc-mismatched-primary-app" + subnet_ids = ["subnet-mismatched-primary-app"] + } } } multi_runner_config = { @@ -2230,10 +2273,12 @@ run "experimental_v2_prefers_nested_primary_github_app_over_flat" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2292,9 +2337,11 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-mismatched-additional-apps" - subnet_ids = ["subnet-mismatched-additional-apps"] + aws = { + ec2 = { + vpc_id = "vpc-mismatched-additional-apps" + subnet_ids = ["subnet-mismatched-additional-apps"] + } } } multi_runner_config = { @@ -2327,10 +2374,12 @@ run "experimental_v2_prefers_nested_additional_github_apps_over_flat" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2387,14 +2436,16 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled } } compute_provider = { - ec2 = { - vpc_id = "vpc-disabled-deregistration" - subnet_ids = ["subnet-disabled-deregistration"] - instance_termination_watcher = { - enabled = true - enable_runner_deregistration = false - artifact = { - zip = "README.md" + aws = { + ec2 = { + vpc_id = "vpc-disabled-deregistration" + subnet_ids = ["subnet-disabled-deregistration"] + instance_termination_watcher = { + enabled = true + enable_runner_deregistration = false + artifact = { + zip = "README.md" + } } } } @@ -2429,10 +2480,12 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2444,8 +2497,8 @@ run "experimental_v2_allows_mismatched_watcher_ghes_when_deregistration_disabled assert { condition = ( !var.instance_termination_watcher.enable - && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled - && !local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration + && local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enabled + && !local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration && output.instance_termination_watcher != null && module.runner_configs["disabled_deregistration"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-disabled-deregistration.example.com" ) @@ -2490,14 +2543,16 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-mismatched-watcher-ghes" - subnet_ids = ["subnet-mismatched-watcher-ghes"] - instance_termination_watcher = { - enabled = true - enable_runner_deregistration = true - artifact = { - zip = "README.md" + aws = { + ec2 = { + vpc_id = "vpc-mismatched-watcher-ghes" + subnet_ids = ["subnet-mismatched-watcher-ghes"] + instance_termination_watcher = { + enabled = true + enable_runner_deregistration = true + artifact = { + zip = "README.md" + } } } } @@ -2532,10 +2587,12 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2547,8 +2604,8 @@ run "experimental_v2_termination_watcher_ignores_mismatched_flat_ghes_url" { assert { condition = ( !var.instance_termination_watcher.enable - && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enabled - && local.translated_experimental.compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration + && local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enabled + && local.translated_experimental.compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration && output.instance_termination_watcher.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" && module.runner_configs["mismatched_watcher_ghes"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["GHES_URL"] == "https://experimental-watcher.example.com" && var.ghes_url == "https://flat-watcher.example.com" @@ -2595,9 +2652,11 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { architecture = "x64" } compute_provider = { - ec2 = { - vpc_id = "vpc-flat-only-kms" - subnet_ids = ["subnet-flat-only-kms"] + aws = { + ec2 = { + vpc_id = "vpc-flat-only-kms" + subnet_ids = ["subnet-flat-only-kms"] + } } } multi_runner_config = { @@ -2621,10 +2680,12 @@ run "experimental_v2_ignores_flat_only_shared_ssm_kms_key" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2684,9 +2745,11 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-only" } compute_provider = { - ec2 = { - vpc_id = "vpc-experimental-only-kms" - subnet_ids = ["subnet-experimental-only-kms"] + aws = { + ec2 = { + vpc_id = "vpc-experimental-only-kms" + subnet_ids = ["subnet-experimental-only-kms"] + } } } multi_runner_config = { @@ -2710,10 +2773,12 @@ run "experimental_v2_uses_experimental_only_shared_ssm_kms_key" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2773,9 +2838,11 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/experimental-mismatch" } compute_provider = { - ec2 = { - vpc_id = "vpc-mismatched-kms" - subnet_ids = ["subnet-mismatched-kms"] + aws = { + ec2 = { + vpc_id = "vpc-mismatched-kms" + subnet_ids = ["subnet-mismatched-kms"] + } } } multi_runner_config = { @@ -2799,10 +2866,12 @@ run "experimental_v2_prefers_nested_shared_ssm_kms_key_over_flat" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2860,9 +2929,11 @@ run "experimental_v2_external_role_ignores_global_iam_management" { } compute_provider = { - ec2 = { - vpc_id = "vpc-external-role" - subnet_ids = ["subnet-external-role"] + aws = { + ec2 = { + vpc_id = "vpc-external-role" + subnet_ids = ["subnet-external-role"] + } } } @@ -2901,10 +2972,12 @@ run "experimental_v2_external_role_ignores_global_iam_management" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -2952,9 +3025,11 @@ run "experimental_v2_rejects_explicit_iam_management_with_external_role" { } compute_provider = { - ec2 = { - vpc_id = "vpc-invalid-external-role" - subnet_ids = ["subnet-invalid-external-role"] + aws = { + ec2 = { + vpc_id = "vpc-invalid-external-role" + subnet_ids = ["subnet-invalid-external-role"] + } } } @@ -2990,10 +3065,12 @@ run "experimental_v2_rejects_explicit_iam_management_with_external_role" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -3057,9 +3134,11 @@ run "experimental_v2_layers_shared_and_component_tags" { } compute_provider = { - ec2 = { - vpc_id = "vpc-tagged" - subnet_ids = ["subnet-tagged"] + aws = { + ec2 = { + vpc_id = "vpc-tagged" + subnet_ids = ["subnet-tagged"] + } } } @@ -3147,10 +3226,12 @@ run "experimental_v2_layers_shared_and_component_tags" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -3285,9 +3366,11 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window } } compute_provider = { - ec2 = { - vpc_id = "vpc-invalid-visibility" - subnet_ids = ["subnet-invalid-visibility"] + aws = { + ec2 = { + vpc_id = "vpc-invalid-visibility" + subnet_ids = ["subnet-invalid-visibility"] + } } } @@ -3325,10 +3408,12 @@ run "experimental_v2_rejects_visibility_timeout_shorter_than_lambda_retry_window } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -3368,9 +3453,11 @@ run "experimental_v2_rejects_conflicting_queue_encryption" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-invalid-encryption" - subnet_ids = ["subnet-invalid-encryption"] + aws = { + ec2 = { + vpc_id = "vpc-invalid-encryption" + subnet_ids = ["subnet-invalid-encryption"] + } } } @@ -3394,8 +3481,10 @@ run "experimental_v2_rejects_conflicting_queue_encryption" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -3434,9 +3523,11 @@ run "experimental_v2_rejects_queue_kms_alias" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-invalid-queue-kms" - subnet_ids = ["subnet-invalid-queue-kms"] + aws = { + ec2 = { + vpc_id = "vpc-invalid-queue-kms" + subnet_ids = ["subnet-invalid-queue-kms"] + } } } @@ -3460,10 +3551,12 @@ run "experimental_v2_rejects_queue_kms_alias" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -3503,9 +3596,11 @@ run "experimental_v2_rejects_queue_kms_key_id" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-invalid-queue-kms-key-id" - subnet_ids = ["subnet-invalid-queue-kms-key-id"] + aws = { + ec2 = { + vpc_id = "vpc-invalid-queue-kms-key-id" + subnet_ids = ["subnet-invalid-queue-kms-key-id"] + } } } @@ -3529,10 +3624,12 @@ run "experimental_v2_rejects_queue_kms_key_id" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -3570,9 +3667,11 @@ run "experimental_v2_rejects_redrive_without_max_receive_count" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-missing-redrive-max" - subnet_ids = ["subnet-missing-redrive-max"] + aws = { + ec2 = { + vpc_id = "vpc-missing-redrive-max" + subnet_ids = ["subnet-missing-redrive-max"] + } } } multi_runner_config = { @@ -3595,10 +3694,12 @@ run "experimental_v2_rejects_redrive_without_max_receive_count" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -3627,9 +3728,11 @@ run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-nonpositive-redrive-max" - subnet_ids = ["subnet-nonpositive-redrive-max"] + aws = { + ec2 = { + vpc_id = "vpc-nonpositive-redrive-max" + subnet_ids = ["subnet-nonpositive-redrive-max"] + } } } multi_runner_config = { @@ -3659,10 +3762,12 @@ run "experimental_v2_rejects_nonpositive_redrive_max_receive_count" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -3711,9 +3816,11 @@ run "experimental_v2_rejects_runner_artifact_zip_and_s3" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-conflicting-runner-artifact" - subnet_ids = ["subnet-conflicting-runner-artifact"] + aws = { + ec2 = { + vpc_id = "vpc-conflicting-runner-artifact" + subnet_ids = ["subnet-conflicting-runner-artifact"] + } } } multi_runner_config = { @@ -3736,8 +3843,10 @@ run "experimental_v2_rejects_runner_artifact_zip_and_s3" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -3784,9 +3893,11 @@ run "experimental_v2_rejects_runner_artifact_bucket_without_key" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-missing-runner-artifact-key" - subnet_ids = ["subnet-missing-runner-artifact-key"] + aws = { + ec2 = { + vpc_id = "vpc-missing-runner-artifact-key" + subnet_ids = ["subnet-missing-runner-artifact-key"] + } } } multi_runner_config = { @@ -3809,8 +3920,10 @@ run "experimental_v2_rejects_runner_artifact_bucket_without_key" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -3849,9 +3962,11 @@ run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-missing-runner-artifact-bucket" - subnet_ids = ["subnet-missing-runner-artifact-bucket"] + aws = { + ec2 = { + vpc_id = "vpc-missing-runner-artifact-bucket" + subnet_ids = ["subnet-missing-runner-artifact-bucket"] + } } } multi_runner_config = { @@ -3874,8 +3989,10 @@ run "experimental_v2_rejects_runner_artifact_s3_without_bucket" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -3922,9 +4039,11 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_zip_and_s3" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-conflicting-ssm-housekeeper-artifact" - subnet_ids = ["subnet-conflicting-ssm-housekeeper-artifact"] + aws = { + ec2 = { + vpc_id = "vpc-conflicting-ssm-housekeeper-artifact" + subnet_ids = ["subnet-conflicting-ssm-housekeeper-artifact"] + } } } multi_runner_config = { @@ -3945,8 +4064,10 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_zip_and_s3" { } } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -3985,9 +4106,11 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_bucket" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-missing-ssm-housekeeper-artifact-bucket" - subnet_ids = ["subnet-missing-ssm-housekeeper-artifact-bucket"] + aws = { + ec2 = { + vpc_id = "vpc-missing-ssm-housekeeper-artifact-bucket" + subnet_ids = ["subnet-missing-ssm-housekeeper-artifact-bucket"] + } } } multi_runner_config = { @@ -4008,8 +4131,10 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_bucket" { } } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -4055,9 +4180,11 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_key" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-missing-ssm-housekeeper-artifact-key" - subnet_ids = ["subnet-missing-ssm-housekeeper-artifact-key"] + aws = { + ec2 = { + vpc_id = "vpc-missing-ssm-housekeeper-artifact-key" + subnet_ids = ["subnet-missing-ssm-housekeeper-artifact-key"] + } } } multi_runner_config = { @@ -4078,8 +4205,10 @@ run "experimental_v2_rejects_ssm_housekeeper_artifact_s3_without_key" { } } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } @@ -4114,15 +4243,17 @@ run "experimental_v2_rejects_runner_binaries_artifact_zip_and_s3" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-conflicting-binary-artifact" - subnet_ids = ["subnet-conflicting-binary-artifact"] - runner_binaries = { - syncer = { - artifact = { - zip = "runner-binaries-syncer.zip" - s3 = { - key = "runner-binaries-syncer.zip" + aws = { + ec2 = { + vpc_id = "vpc-conflicting-binary-artifact" + subnet_ids = ["subnet-conflicting-binary-artifact"] + runner_binaries = { + syncer = { + artifact = { + zip = "runner-binaries-syncer.zip" + s3 = { + key = "runner-binaries-syncer.zip" + } } } } @@ -4149,10 +4280,12 @@ run "experimental_v2_rejects_runner_binaries_artifact_zip_and_s3" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -4181,13 +4314,15 @@ run "experimental_v2_rejects_runner_binaries_logging_prefix_without_bucket" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-binary-logging-prefix" - subnet_ids = ["subnet-binary-logging-prefix"] - runner_binaries = { - s3 = { - logging = { - prefix = "runner-binaries/" + aws = { + ec2 = { + vpc_id = "vpc-binary-logging-prefix" + subnet_ids = ["subnet-binary-logging-prefix"] + runner_binaries = { + s3 = { + logging = { + prefix = "runner-binaries/" + } } } } @@ -4213,10 +4348,12 @@ run "experimental_v2_rejects_runner_binaries_logging_prefix_without_bucket" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -4276,9 +4413,11 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-mismatched-queue-kms" - subnet_ids = ["subnet-mismatched-queue-kms"] + aws = { + ec2 = { + vpc_id = "vpc-mismatched-queue-kms" + subnet_ids = ["subnet-mismatched-queue-kms"] + } } } @@ -4302,10 +4441,12 @@ run "experimental_v2_accepts_distinct_queue_and_ssm_kms_keys" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } } } } @@ -4342,9 +4483,11 @@ run "experimental_v2_rejects_empty_compute_provider" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-invalid-provider" - subnet_ids = ["subnet-invalid-provider"] + aws = { + ec2 = { + vpc_id = "vpc-invalid-provider" + subnet_ids = ["subnet-invalid-provider"] + } } } @@ -4393,9 +4536,11 @@ run "experimental_v2_rejects_invalid_ssm_housekeeper_state" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-invalid-housekeeper" - subnet_ids = ["subnet-invalid-housekeeper"] + aws = { + ec2 = { + vpc_id = "vpc-invalid-housekeeper" + subnet_ids = ["subnet-invalid-housekeeper"] + } } } @@ -4425,8 +4570,10 @@ run "experimental_v2_rejects_invalid_ssm_housekeeper_state" { } compute_provider = { - ec2 = { - instance_types = ["m5.large"] + aws = { + ec2 = { + instance_types = ["m5.large"] + } } } } diff --git a/modules/multi-runner/validations.experimental.tf b/modules/multi-runner/validations.experimental.tf index 3885c3648f..1140270ccb 100644 --- a/modules/multi-runner/validations.experimental.tf +++ b/modules/multi-runner/validations.experimental.tf @@ -57,12 +57,15 @@ resource "terraform_data" "validate_experimental" { precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : - length([ - for provider_type, provider_config in runner_config.compute_provider : provider_type - if provider_config != null - ]) == 1 + length(flatten([ + for provider_namespace, provider_configs in runner_config.compute_provider : [ + for provider_type, provider_config in provider_configs : + "${provider_namespace}.${provider_type}" + if provider_config != null + ] + ])) == 1 ]) - error_message = "Each experimental runner configuration must set exactly one compute-provider block. Supported compute-provider blocks: ec2." + error_message = "Each experimental runner configuration must set exactly one compute-provider block. Supported compute-provider blocks: aws.ec2." } precondition { @@ -112,12 +115,12 @@ resource "terraform_data" "validate_experimental" { precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : - runner_config.compute_provider.ec2 == null ? true : ( - try(coalesce(runner_config.compute_provider.ec2.vpc_id, var.experimental.compute_provider.ec2.vpc_id), null) != null && - try(coalesce(runner_config.compute_provider.ec2.subnet_ids, var.experimental.compute_provider.ec2.subnet_ids), null) != null + runner_config.compute_provider.aws.ec2 == null ? true : ( + try(coalesce(runner_config.compute_provider.aws.ec2.vpc_id, var.experimental.compute_provider.aws.ec2.vpc_id), null) != null && + try(coalesce(runner_config.compute_provider.aws.ec2.subnet_ids, var.experimental.compute_provider.aws.ec2.subnet_ids), null) != null ) ]) - error_message = "Each experimental EC2 runner configuration must resolve compute_provider.ec2.vpc_id and subnet_ids from the configuration or experimental global EC2 defaults. Flat v1 inputs are not inherited by v2." + error_message = "Each experimental EC2 runner configuration must resolve compute_provider.aws.ec2.vpc_id and subnet_ids from the configuration or experimental global EC2 defaults. Flat v1 inputs are not inherited by v2." } precondition { @@ -240,23 +243,23 @@ resource "terraform_data" "validate_experimental" { precondition { condition = !local.use_multi_runner_config_v2 || !anytrue([ for runner_config in values(local.translated_experimental_base.multi_runner_config) : - try(runner_config.compute_provider.ec2.binaries_syncer.enabled, false) + try(runner_config.compute_provider.aws.ec2.binaries_syncer.enabled, false) ]) || contains( ["Disabled", "Enabled", "Suspended"], - var.experimental.compute_provider.ec2.runner_binaries.s3.versioning, + var.experimental.compute_provider.aws.ec2.runner_binaries.s3.versioning, ) - error_message = "experimental.compute_provider.ec2.runner_binaries.s3.versioning must be Disabled, Enabled, or Suspended." + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.s3.versioning must be Disabled, Enabled, or Suspended." } precondition { condition = !local.use_multi_runner_config_v2 || !anytrue([ for runner_config in values(local.translated_experimental_base.multi_runner_config) : - try(runner_config.compute_provider.ec2.binaries_syncer.enabled, false) + try(runner_config.compute_provider.aws.ec2.binaries_syncer.enabled, false) ]) || contains( ["DISABLED", "ENABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], - var.experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state, + var.experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.state, ) - error_message = "experimental.compute_provider.ec2.runner_binaries.syncer.schedule.state must be DISABLED, ENABLED, or ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS." + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.state must be DISABLED, ENABLED, or ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS." } precondition { @@ -334,72 +337,72 @@ resource "terraform_data" "validate_experimental" { precondition { condition = !local.use_multi_runner_config_v2 || ( !( - var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.zip != null && - var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3 != null + var.experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.zip != null && + var.experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3 != null ) && ( - var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3 == null || ( + var.experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3 == null || ( var.experimental.lambda.artifact.s3.bucket != null && - try(var.experimental.compute_provider.ec2.instance_termination_watcher.artifact.s3.key != null, false) + try(var.experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key != null, false) ) ) ) - error_message = "experimental.compute_provider.ec2.instance_termination_watcher.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.compute_provider.aws.ec2.instance_termination_watcher.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { condition = !local.use_multi_runner_config_v2 || ( !( - var.experimental.compute_provider.ec2.ami.housekeeper.artifact.zip != null && - var.experimental.compute_provider.ec2.ami.housekeeper.artifact.s3 != null + var.experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.zip != null && + var.experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3 != null ) && ( - var.experimental.compute_provider.ec2.ami.housekeeper.artifact.s3 == null || ( + var.experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3 == null || ( var.experimental.lambda.artifact.s3.bucket != null && - try(var.experimental.compute_provider.ec2.ami.housekeeper.artifact.s3.key != null, false) + try(var.experimental.compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key != null, false) ) ) ) - error_message = "experimental.compute_provider.ec2.ami.housekeeper.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.compute_provider.aws.ec2.ami.housekeeper.artifact must set at most one of zip or s3; an s3 wrapper requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { condition = !local.use_multi_runner_config_v2 || !( - var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.zip != null && - var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 != null + var.experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip != null && + var.experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3 != null ) - error_message = "experimental.compute_provider.ec2.runner_binaries.syncer.artifact must set at most one of zip or s3." + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact must set at most one of zip or s3." } precondition { condition = !local.use_multi_runner_config_v2 || ( - var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 == null ? true : ( + var.experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3 == null ? true : ( var.experimental.lambda.artifact.s3.bucket != null && - var.experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3.key != null + var.experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key != null ) ) - error_message = "experimental.compute_provider.ec2.runner_binaries.syncer.artifact.s3 requires experimental.lambda.artifact.s3.bucket and a non-null key." + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3 requires experimental.lambda.artifact.s3.bucket and a non-null key." } precondition { condition = !local.use_multi_runner_config_v2 || contains( ["AES256", "aws:kms", "aws:kms:dsse"], - var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm, + var.experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm, ) - error_message = "experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm must be AES256, aws:kms, or aws:kms:dsse." + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm must be AES256, aws:kms, or aws:kms:dsse." } precondition { condition = !local.use_multi_runner_config_v2 || ( - !var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.enabled ? ( - var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id == null + !var.experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled ? ( + var.experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id == null ) : ( - var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id == null || + var.experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id == null || contains( ["aws:kms", "aws:kms:dsse"], - var.experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm, + var.experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm, ) ) ) - error_message = "experimental.compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm must be aws:kms or aws:kms:dsse when kms_master_key_id is set." + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm must be aws:kms or aws:kms:dsse when kms_master_key_id is set." } precondition { @@ -417,10 +420,10 @@ resource "terraform_data" "validate_experimental" { precondition { condition = !local.use_multi_runner_config_v2 || ( - var.experimental.compute_provider.ec2.runner_binaries.s3.logging.prefix == null || - var.experimental.compute_provider.ec2.runner_binaries.s3.logging.bucket != null + var.experimental.compute_provider.aws.ec2.runner_binaries.s3.logging.prefix == null || + var.experimental.compute_provider.aws.ec2.runner_binaries.s3.logging.bucket != null ) - error_message = "experimental.compute_provider.ec2.runner_binaries.s3.logging.prefix requires logging.bucket." + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.s3.logging.prefix requires logging.bucket." } precondition { diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index e5a775e98d..34990b4dbb 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -169,80 +169,83 @@ variable "experimental" { - `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`. - `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`. - `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`. - - `compute_provider.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally. - - `compute_provider.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally. - - `compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`. - - `compute_provider.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`. - - `compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule. - - `compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule. - - `compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule. - - `compute_provider.ec2.egress_rules[].from_port`: Start of the egress rule port range. - - `compute_provider.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol. - - `compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule. - - `compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. - - `compute_provider.ec2.egress_rules[].to_port`: End of the egress rule port range. - - `compute_provider.ec2.egress_rules[].description`: Optional egress rule description. - - `compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`. - - `compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned. - - `compute_provider.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null. - - `compute_provider.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null. - - `compute_provider.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`. - - `compute_provider.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence. - - `compute_provider.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda. - - `compute_provider.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance. - - `compute_provider.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module. - - `compute_provider.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum. - - `compute_provider.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`. - - `compute_provider.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`. - - `compute_provider.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates. - - `compute_provider.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters. - - `compute_provider.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`. - - `compute_provider.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. - - `compute_provider.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null. - - `compute_provider.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. - - `compute_provider.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive. - - `compute_provider.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null. - - `compute_provider.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`. - - `compute_provider.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`. - - `compute_provider.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`. - - `compute_provider.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher. - - `compute_provider.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance. - - `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources. - - `compute_provider.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources. - - `compute_provider.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources. - - `compute_provider.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`. - - `compute_provider.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. - - `compute_provider.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null. - - `compute_provider.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. - - `compute_provider.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive. - - `compute_provider.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null. - - `compute_provider.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module. - - `compute_provider.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module. - - `compute_provider.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair. - - `compute_provider.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances. - - `compute_provider.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket. - - `compute_provider.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket. - - `compute_provider.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false. - - `compute_provider.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null. - - `compute_provider.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms. - - `compute_provider.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK. - - `compute_provider.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`. - - `compute_provider.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead. - - `compute_provider.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket. - - `compute_provider.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource. - - `compute_provider.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`. - - `compute_provider.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda. - - `compute_provider.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. - - `compute_provider.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null. - - `compute_provider.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. - - `compute_provider.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present. - - `compute_provider.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null. - - `compute_provider.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks. - - `compute_provider.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`. - - `compute_provider.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`. - - `compute_provider.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda. - - `compute_provider.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`. - - `compute_provider.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`. + - `compute_provider`: Shared compute-provider defaults grouped first by cloud and then by provider type. Global defaults do not select a provider for any runner configuration. + - `compute_provider.aws`: Shared defaults for AWS compute providers. + - `compute_provider.aws.ec2`: Shared defaults for AWS EC2 runner configurations. + - `compute_provider.aws.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally. + - `compute_provider.aws.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally. + - `compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`. + - `compute_provider.aws.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`. + - `compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule. + - `compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule. + - `compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule. + - `compute_provider.aws.ec2.egress_rules[].from_port`: Start of the egress rule port range. + - `compute_provider.aws.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol. + - `compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule. + - `compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. + - `compute_provider.aws.ec2.egress_rules[].to_port`: End of the egress rule port range. + - `compute_provider.aws.ec2.egress_rules[].description`: Optional egress rule description. + - `compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`. + - `compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned. + - `compute_provider.aws.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null. + - `compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null. + - `compute_provider.aws.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`. + - `compute_provider.aws.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence. + - `compute_provider.aws.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda. + - `compute_provider.aws.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance. + - `compute_provider.aws.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module. + - `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum. + - `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`. + - `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`. + - `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates. + - `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters. + - `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`. + - `compute_provider.aws.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `compute_provider.aws.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null. + - `compute_provider.aws.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. + - `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive. + - `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null. + - `compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`. + - `compute_provider.aws.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`. + - `compute_provider.aws.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`. + - `compute_provider.aws.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher. + - `compute_provider.aws.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance. + - `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources. + - `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources. + - `compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources. + - `compute_provider.aws.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`. + - `compute_provider.aws.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `compute_provider.aws.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null. + - `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. + - `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive. + - `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null. + - `compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module. + - `compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module. + - `compute_provider.aws.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair. + - `compute_provider.aws.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.aws.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances. + - `compute_provider.aws.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket. + - `compute_provider.aws.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket. + - `compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false. + - `compute_provider.aws.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null. + - `compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms. + - `compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK. + - `compute_provider.aws.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`. + - `compute_provider.aws.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead. + - `compute_provider.aws.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket. + - `compute_provider.aws.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource. + - `compute_provider.aws.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`. + - `compute_provider.aws.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda. + - `compute_provider.aws.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used. + - `compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null. + - `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key. + - `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present. + - `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null. + - `compute_provider.aws.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks. + - `compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`. + - `compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`. + - `compute_provider.aws.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda. + - `compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`. + - `compute_provider.aws.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`. Each `experimental.multi_runner_config` entry supports the following nested fields: @@ -362,90 +365,91 @@ variable "experimental" { - `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics. - `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics. - `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics. - - `multi_runner_config[].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. - - `multi_runner_config[].compute_provider.ec2`: EC2-specific configuration. - - `multi_runner_config[].compute_provider.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled. - - `multi_runner_config[].compute_provider.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. - - `multi_runner_config[].compute_provider.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. - - `multi_runner_config[].compute_provider.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. - - `multi_runner_config[].compute_provider.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter. - - `multi_runner_config[].compute_provider.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. - - `multi_runner_config[].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. - - `multi_runner_config[].compute_provider.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply. - - `multi_runner_config[].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. - - `multi_runner_config[].compute_provider.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings`: EBS mappings added to the runner launch template. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].encrypted`: Enables EBS encryption. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_size`: Volume size in GiB. - - `multi_runner_config[].compute_provider.ec2.block_device_mappings[].volume_type`: EBS volume type. - - `multi_runner_config[].compute_provider.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role. - - `multi_runner_config[].compute_provider.ec2.credit_specification`: CPU credit mode for burstable instance types. - - `multi_runner_config[].compute_provider.ec2.ebs_optimized`: Requests EBS-optimized runner instances. - - `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow. - - `multi_runner_config[].compute_provider.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. - - `multi_runner_config[].compute_provider.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.ec2.runner_binaries.enabled`. - - `multi_runner_config[].compute_provider.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances. - - `multi_runner_config[].compute_provider.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access. - - `multi_runner_config[].compute_provider.ec2.user_data.enabled`: Enables launch-template user data. - - `multi_runner_config[].compute_provider.ec2.user_data.template`: Optional path to a custom user-data template. - - `multi_runner_config[].compute_provider.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template. - - `multi_runner_config[].compute_provider.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template. - - `multi_runner_config[].compute_provider.ec2.user_data.post_install`: Script content inserted after runner installation in the default template. - - `multi_runner_config[].compute_provider.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs. - - `multi_runner_config[].compute_provider.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity. - - `multi_runner_config[].compute_provider.ec2.instance_max_spot_price`: Optional maximum hourly Spot price. - - `multi_runner_config[].compute_provider.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`. - - `multi_runner_config[].compute_provider.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type. - - `multi_runner_config[].compute_provider.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions. - - `multi_runner_config[].compute_provider.ec2.additional_security_group_ids`: Existing security groups attached to runner instances. - - `multi_runner_config[].compute_provider.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true. - - `multi_runner_config[].compute_provider.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range. - - `multi_runner_config[].compute_provider.ec2.egress_rules[].description`: Optional runner-configuration egress rule description. - - `multi_runner_config[].compute_provider.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile. - - `multi_runner_config[].compute_provider.ec2.key_name`: Optional EC2 key-pair name for runner instances. - - `multi_runner_config[].compute_provider.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances. - - `multi_runner_config[].compute_provider.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`. - - `multi_runner_config[].compute_provider.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure. - - `multi_runner_config[].compute_provider.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures. - - `multi_runner_config[].compute_provider.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value. - - `multi_runner_config[].compute_provider.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value. - - `multi_runner_config[].compute_provider.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance. - - `multi_runner_config[].compute_provider.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core. - - `multi_runner_config[].compute_provider.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. - - `multi_runner_config[].compute_provider.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types. - - `multi_runner_config[].compute_provider.ec2.placement.affinity`: Host affinity setting. - - `multi_runner_config[].compute_provider.ec2.placement.availability_zone`: Availability Zone in which the instance is placed. - - `multi_runner_config[].compute_provider.ec2.placement.group_id`: Placement-group ID. - - `multi_runner_config[].compute_provider.ec2.placement.group_name`: Placement-group name. - - `multi_runner_config[].compute_provider.ec2.placement.host_id`: Dedicated Host ID. - - `multi_runner_config[].compute_provider.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement. - - `multi_runner_config[].compute_provider.ec2.placement.spread_domain`: Spread-domain placement value. - - `multi_runner_config[].compute_provider.ec2.placement.tenancy`: Instance tenancy. - - `multi_runner_config[].compute_provider.ec2.placement.partition_number`: Placement-group partition number. - - `multi_runner_config[].compute_provider.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration. - - `multi_runner_config[].compute_provider.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners. - - `multi_runner_config[].compute_provider.ec2.log_files`: Optional log files collected by the CloudWatch agent. - - `multi_runner_config[].compute_provider.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing. - - `multi_runner_config[].compute_provider.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true. - - `multi_runner_config[].compute_provider.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent. - - `multi_runner_config[].compute_provider.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template. - - `multi_runner_config[].compute_provider.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file. - - `multi_runner_config[].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. + - `multi_runner_config[].compute_provider`: Typed compute-provider namespaces. Exactly one nested provider block must be non-null, and that populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply. + - `multi_runner_config[].compute_provider.aws`: AWS compute-provider namespace. The namespace itself does not select a provider. + - `multi_runner_config[].compute_provider.aws.ec2`: AWS EC2-specific configuration. A non-null block selects AWS EC2 for this runner configuration. + - `multi_runner_config[].compute_provider.aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled. + - `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. + - `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. + - `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. + - `multi_runner_config[].compute_provider.aws.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter. + - `multi_runner_config[].compute_provider.aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. + - `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time. + - `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply. + - `multi_runner_config[].compute_provider.aws.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. + - `multi_runner_config[].compute_provider.aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB. + - `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_type`: EBS volume type. + - `multi_runner_config[].compute_provider.aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role. + - `multi_runner_config[].compute_provider.aws.ec2.credit_specification`: CPU credit mode for burstable instance types. + - `multi_runner_config[].compute_provider.aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances. + - `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow. + - `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. + - `multi_runner_config[].compute_provider.aws.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.aws.ec2.runner_binaries.enabled`. + - `multi_runner_config[].compute_provider.aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances. + - `multi_runner_config[].compute_provider.aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access. + - `multi_runner_config[].compute_provider.aws.ec2.user_data.enabled`: Enables launch-template user data. + - `multi_runner_config[].compute_provider.aws.ec2.user_data.template`: Optional path to a custom user-data template. + - `multi_runner_config[].compute_provider.aws.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template. + - `multi_runner_config[].compute_provider.aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template. + - `multi_runner_config[].compute_provider.aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template. + - `multi_runner_config[].compute_provider.aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs. + - `multi_runner_config[].compute_provider.aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity. + - `multi_runner_config[].compute_provider.aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price. + - `multi_runner_config[].compute_provider.aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`. + - `multi_runner_config[].compute_provider.aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type. + - `multi_runner_config[].compute_provider.aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions. + - `multi_runner_config[].compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to runner instances. + - `multi_runner_config[].compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.aws.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range. + - `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].description`: Optional runner-configuration egress rule description. + - `multi_runner_config[].compute_provider.aws.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile. + - `multi_runner_config[].compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name for runner instances. + - `multi_runner_config[].compute_provider.aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances. + - `multi_runner_config[].compute_provider.aws.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`. + - `multi_runner_config[].compute_provider.aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure. + - `multi_runner_config[].compute_provider.aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures. + - `multi_runner_config[].compute_provider.aws.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value. + - `multi_runner_config[].compute_provider.aws.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value. + - `multi_runner_config[].compute_provider.aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance. + - `multi_runner_config[].compute_provider.aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core. + - `multi_runner_config[].compute_provider.aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. + - `multi_runner_config[].compute_provider.aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types. + - `multi_runner_config[].compute_provider.aws.ec2.placement.affinity`: Host affinity setting. + - `multi_runner_config[].compute_provider.aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed. + - `multi_runner_config[].compute_provider.aws.ec2.placement.group_id`: Placement-group ID. + - `multi_runner_config[].compute_provider.aws.ec2.placement.group_name`: Placement-group name. + - `multi_runner_config[].compute_provider.aws.ec2.placement.host_id`: Dedicated Host ID. + - `multi_runner_config[].compute_provider.aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement. + - `multi_runner_config[].compute_provider.aws.ec2.placement.spread_domain`: Spread-domain placement value. + - `multi_runner_config[].compute_provider.aws.ec2.placement.tenancy`: Instance tenancy. + - `multi_runner_config[].compute_provider.aws.ec2.placement.partition_number`: Placement-group partition number. + - `multi_runner_config[].compute_provider.aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration. + - `multi_runner_config[].compute_provider.aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners. + - `multi_runner_config[].compute_provider.aws.ec2.log_files`: Optional log files collected by the CloudWatch agent. + - `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing. + - `multi_runner_config[].compute_provider.aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true. + - `multi_runner_config[].compute_provider.aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent. + - `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template. + - `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file. + - `multi_runner_config[].compute_provider.aws.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. EOT type = object({ @@ -707,53 +711,78 @@ variable "experimental" { }), {}) compute_provider = optional(object({ - ec2 = optional(object({ - vpc_id = optional(string, null) - subnet_ids = optional(list(string), null) - managed_security_group_enabled = optional(bool, true) - 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 - }]) - additional_security_group_ids = optional(list(string), []) - cloudwatch_agent = optional(object({ - config = optional(string, null) - }), {}) - instance_profile_path = optional(string, null) - key_name = optional(string, null) - associate_public_ipv4_address = optional(bool, false) - tags = optional(map(string), {}) - ami = optional(object({ - housekeeper = optional(object({ + aws = optional(object({ + ec2 = optional(object({ + vpc_id = optional(string, null) + subnet_ids = optional(list(string), null) + managed_security_group_enabled = optional(bool, true) + 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 + }]) + additional_security_group_ids = optional(list(string), []) + cloudwatch_agent = optional(object({ + config = optional(string, null) + }), {}) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, false) + tags = optional(map(string), {}) + ami = optional(object({ + housekeeper = optional(object({ + enabled = optional(bool, false) + cleanup_config = optional(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) + }), {}) + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + timeout = optional(number, 300) + }), {}) + schedule = optional(object({ + expression = optional(string, "cron(11 7 * * ? *)") + }), {}) + }), {}) + }), {}) + instance_termination_watcher = optional(object({ enabled = optional(bool, false) - cleanup_config = optional(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) + features = optional(object({ + enable_spot_termination_handler = optional(bool, true) + enable_spot_termination_notification_watcher = optional(bool, true) }), {}) + enable_runner_deregistration = optional(bool, true) + environment_variables = optional(map(string), {}) artifact = optional(object({ zip = optional(string, null) s3 = optional(object({ @@ -762,65 +791,42 @@ variable "experimental" { }), null) }), {}) lambda = optional(object({ - memory_size = optional(number, 256) - timeout = optional(number, 300) - }), {}) - schedule = optional(object({ - expression = optional(string, "cron(11 7 * * ? *)") + memory_size = optional(number, null) + timeout = optional(number, null) }), {}) }), {}) - }), {}) - instance_termination_watcher = optional(object({ - enabled = optional(bool, false) - features = optional(object({ - enable_spot_termination_handler = optional(bool, true) - enable_spot_termination_notification_watcher = optional(bool, true) - }), {}) - enable_runner_deregistration = optional(bool, true) - environment_variables = optional(map(string), {}) - artifact = optional(object({ - zip = optional(string, null) + runner_binaries = optional(object({ + enabled = optional(bool, true) s3 = optional(object({ - key = string - object_version = optional(string, null) - }), null) - }), {}) - lambda = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - }), {}) - }), {}) - runner_binaries = optional(object({ - enabled = optional(bool, true) - s3 = optional(object({ - encryption = optional(object({ - enabled = optional(bool, true) - bucket_key_enabled = optional(bool, null) - sse_algorithm = optional(string, "AES256") - kms_master_key_id = optional(string, null) - }), {}) - tags = optional(map(string), {}) - versioning = optional(string, "Disabled") - logging = optional(object({ - bucket = optional(string, null) - prefix = optional(string, null) - }), {}) - }), {}) - syncer = optional(object({ - artifact = optional(object({ - zip = optional(string, null) - s3 = optional(object({ - key = string - object_version = optional(string, null) - }), null) - }), {}) - lambda = optional(object({ - memory_size = optional(number, 256) - timeout = optional(number, 300) + encryption = optional(object({ + enabled = optional(bool, true) + bucket_key_enabled = optional(bool, null) + sse_algorithm = optional(string, "AES256") + kms_master_key_id = optional(string, null) + }), {}) + tags = optional(map(string), {}) + versioning = optional(string, "Disabled") + logging = optional(object({ + bucket = optional(string, null) + prefix = optional(string, null) + }), {}) }), {}) - schedule = optional(object({ - expression = optional(string, "cron(27 * * * ? *)") - state = optional(string, "ENABLED") + syncer = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + timeout = optional(number, 300) + }), {}) + schedule = optional(object({ + expression = optional(string, "cron(27 * * * ? *)") + state = optional(string, "ENABLED") + }), {}) }), {}) }), {}) }), {}) @@ -1024,125 +1030,127 @@ variable "experimental" { }), {}) 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 + aws = optional(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) - kms_key = optional(object({ - arn = string + 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, null) + }), {}) + 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), null) + managed_security_group_enabled = optional(bool, null) + 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 + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) + 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) - 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, null) - }), {}) - 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), null) - managed_security_group_enabled = optional(bool, null) - 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 - })), null) - instance_profile_path = optional(string, null) - key_name = optional(string, null) - associate_public_ipv4_address = optional(bool, null) - 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) + }), {}) }) })), {}) diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index c68133115b..a33d777edd 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -10,9 +10,9 @@ Runner demand orchestration is selected independently through `orchestration_pro Common `lambda` contains only shared execution substrate and the optional shared artifact bucket. The webhook provider owns the runner-control archive shared by scale, pool, and job-retry at `orchestration_provider.webhook.lambda.artifact` and combines its zip or S3 key/version with that common substrate. The common SSM housekeeper independently owns `ssm.housekeeper.lambda.artifact`: an S3 selection combines its component key/version with the common bucket, a local zip is used otherwise when configured, and the packaged runner control-plane archive is the final fallback. It never inherits the webhook runner-control archive. -Provider-owned settings remain nested under a typed provider block. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.ec2`. `multi-runner` resolves experimental globals and runner-configuration overrides first, then its final forwarding adapter preserves the wrapped `{ ec2 = ... }` object expected by this module. Exactly one provider block must be non-null, and the configuration module derives its provider type from that block rather than from a separate discriminator. +Provider-owned settings remain nested under a typed namespace and provider leaf. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.aws.ec2`. `multi-runner` resolves experimental globals and runner-configuration overrides first, then its final forwarding adapter preserves the wrapped `{ aws = { ec2 = ... } }` object expected by this module. Exactly one provider leaf must be non-null. The configuration module flattens the selected namespace and type to the Terraform dispatch key `aws_ec2`, while the webhook runtime registry continues to receive the provider type `ec2`. -The EC2 block reaches runner-config with `compute_provider.ec2.binaries_syncer = { enabled, s3 }`; the S3 object is null when synchronization is disabled. Binary discovery and this shape adaptation happen in `multi-runner`, not inside runner-config. Before creating the common runner role, the configuration module calls the selected provider's isolated `trust-policy` module to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full compute-provider module, which receives the resolved runner role only after it is created. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common configuration module attaches each returned policy group to its runner or webhook-provider role. Provider-specific outputs remain grouped under the matching provider key, such as `provider.ec2`. EC2 is the only implemented Terraform compute provider in this phase. +The EC2 leaf reaches runner-config with `compute_provider.aws.ec2.binaries_syncer = { enabled, s3 }`; the S3 object is null when synchronization is disabled. Binary discovery and this shape adaptation happen in `multi-runner`, not inside runner-config. Before creating the common runner role, the configuration module calls [`compute-providers/aws/ec2/trust-policy`](../compute-providers/aws/ec2/trust-policy) as `module.compute_aws_ec2_trust_policy[0]` to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full [`compute-providers/aws/ec2`](../compute-providers/aws/ec2) module, dispatched at `module.compute_aws_ec2[0]`, which receives the resolved runner role only after it is created. Declarative moved blocks preserve state from the earlier experimental `module.compute_ec2_trust_policy[0]` and `module.compute_ec2[0]` labels. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common configuration module attaches each returned policy group to its runner or webhook-provider role. Provider-specific outputs remain grouped under the matching namespace and provider path, currently `provider.aws.ec2`. Moved blocks do not rewrite output references, so consumers of the former experimental `provider.ec2` path must update their expressions. EC2 is the only implemented Terraform compute provider in this phase. ## Tagging @@ -20,7 +20,7 @@ The EC2 block reaches runner-config with `compute_provider.ec2.binaries_syncer = Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `orchestration_provider.webhook.lambda.scale.up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `orchestration_provider.webhook.lambda.scale.up.tags`. -Provider-specific runner tags remain inside the provider boundary. `compute_provider.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and runner-configuration `compute_provider.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. +Provider-specific runner tags remain inside the provider boundary. `compute_provider.aws.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and runner-configuration `compute_provider.aws.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources. ## Overview @@ -69,30 +69,30 @@ yarn run dist ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -|------|--------|---------| -| [compute\_ec2](#module\_compute\_ec2) | ../compute-providers/ec2 | n/a | -| [compute\_ec2\_trust\_policy](#module\_compute\_ec2\_trust\_policy) | ../compute-providers/ec2/trust-policy | n/a | +| ---- | ------ | ------- | +| [compute\_aws\_ec2](#module\_compute\_aws\_ec2) | ../compute-providers/aws/ec2 | n/a | +| [compute\_aws\_ec2\_trust\_policy](#module\_compute\_aws\_ec2\_trust\_policy) | ../compute-providers/aws/ec2/trust-policy | n/a | | [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | | [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a | ## 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 | @@ -106,10 +106,10 @@ 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.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 runner-configuration 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 configuration 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 | +| [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.

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

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the 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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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({
artifact = optional(object({
s3 = optional(object({
bucket = 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), {})
principals = optional(list(object({
type = string
identifiers = list(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 | @@ -122,10 +122,10 @@ yarn run dist ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | -| [provider](#output\_provider) | Provider-specific resources grouped under the selected provider key. | +| [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. | | [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. Null when webhook orchestration is not configured. | | [scale\_up](#output\_scale\_up) | Scale-up control-plane resources. Null when webhook orchestration is not configured. | diff --git a/modules/runner-config/ec2.tf b/modules/runner-config/compute-provider.aws.ec2.tf similarity index 50% rename from modules/runner-config/ec2.tf rename to modules/runner-config/compute-provider.aws.ec2.tf index 13c085c0c0..5d053a73dc 100644 --- a/modules/runner-config/ec2.tf +++ b/modules/runner-config/compute-provider.aws.ec2.tf @@ -1,20 +1,20 @@ -module "compute_ec2_trust_policy" { - count = local.provider_type == "ec2" ? 1 : 0 - source = "../compute-providers/ec2/trust-policy" +module "compute_aws_ec2_trust_policy" { + count = local.provider_key == "aws_ec2" ? 1 : 0 + source = "../compute-providers/aws/ec2/trust-policy" additional_trust_policy_json = var.runner.iam.additional_trust_policy_json } -module "compute_ec2" { - count = local.provider_type == "ec2" ? 1 : 0 - source = "../compute-providers/ec2" +module "compute_aws_ec2" { + count = local.provider_key == "aws_ec2" ? 1 : 0 + source = "../compute-providers/aws/ec2" aws_partition = var.aws_partition aws_region = var.aws_region prefix = var.prefix tags = var.tags - config = var.compute_provider.ec2 + config = var.compute_provider.aws.ec2 runner = merge(var.runner, { iam = merge(var.runner.iam, { role = local.runner_role @@ -25,3 +25,13 @@ module "compute_ec2" { ssm = var.ssm observability = var.observability } + +moved { + from = module.compute_ec2_trust_policy + to = module.compute_aws_ec2_trust_policy +} + +moved { + from = module.compute_ec2 + to = module.compute_aws_ec2 +} diff --git a/modules/runner-config/compute-provider.tf b/modules/runner-config/compute-provider.tf index 52068c959a..d9bd644d6b 100644 --- a/modules/runner-config/compute-provider.tf +++ b/modules/runner-config/compute-provider.tf @@ -1,18 +1,28 @@ locals { - provider_type = one([ - for provider_type, provider_config in var.compute_provider : provider_type + compute_providers = { + aws_ec2 = var.compute_provider.aws.ec2 + } + + provider_key = one([ + for provider_key, provider_config in local.compute_providers : provider_key if provider_config != null ]) + provider_types = { + aws_ec2 = "ec2" + } + + provider_type = local.provider_types[local.provider_key] + provider_assume_role_policies = { - ec2 = try(module.compute_ec2_trust_policy[0].assume_role_policy, null) + aws_ec2 = try(module.compute_aws_ec2_trust_policy[0].assume_role_policy, null) } - provider_assume_role_policy = local.provider_assume_role_policies[local.provider_type] + provider_assume_role_policy = local.provider_assume_role_policies[local.provider_key] provider_contracts = { - ec2 = one(module.compute_ec2[*].provider) + aws_ec2 = one(module.compute_aws_ec2[*].provider) } - provider_contract = local.provider_contracts[local.provider_type] + provider_contract = local.provider_contracts[local.provider_key] } diff --git a/modules/runner-config/outputs.tf b/modules/runner-config/outputs.tf index 47874d4c62..486e3261eb 100644 --- a/modules/runner-config/outputs.tf +++ b/modules/runner-config/outputs.tf @@ -33,8 +33,10 @@ output "orchestration_provider" { } output "provider" { - description = "Provider-specific resources grouped under the selected provider key." + description = "Provider-specific resources grouped under the selected provider namespace and type." value = { - (local.provider_type) = local.provider_contract.resources + aws = { + ec2 = local.provider_key == "aws_ec2" ? local.provider_contract.resources : null + } } } diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index 9661074158..e1b763f8bd 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -17,26 +17,28 @@ module "external_iam" { prefix = "computed-external" compute_provider = { - ec2 = { - 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}" + aws = { + ec2 = { + 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}" + } } - kms_key = { - arn = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" + instance_profile = { + name = "external-runner-${random_id.external.hex}" + } + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false } - } - instance_profile = { - name = "external-runner-${random_id.external.hex}" - } - cloudwatch_agent = { - enabled = false - } - binaries_syncer = { - enabled = false } } } @@ -124,15 +126,17 @@ module "generated_policy" { prefix = "computed-policy" compute_provider = { - ec2 = { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - instance_types = ["m5.large"] - cloudwatch_agent = { - enabled = false - } - binaries_syncer = { - enabled = false + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } } } } diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl index 6cd0dcf6e5..12a81854c3 100644 --- a/modules/runner-config/tests/pool.tftest.hcl +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -28,26 +28,28 @@ variables { aws_region = "eu-west-1" compute_provider = { - 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" + aws = { + 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 } - kms_key = null - } - binaries_syncer = { - s3 = { - 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" + } } + ssm_enabled = true } - ssm_enabled = true } } @@ -173,13 +175,16 @@ run "plan_with_pool_enabled" { } assert { - condition = toset(keys(output.provider)) == toset(["ec2"]) - error_message = "The runner configuration must expose resources only under the selected provider key." + condition = ( + toset(keys(output.provider)) == toset(["aws"]) + && toset(keys(output.provider.aws)) == toset(["ec2"]) + ) + error_message = "The runner configuration must expose resources under the selected provider namespace and type." } assert { - condition = contains(keys(output.provider.ec2), "launch_template") - error_message = "The runner configuration must expose EC2 resources only under provider.ec2." + condition = contains(keys(output.provider.aws.ec2), "launch_template") + error_message = "The runner configuration must expose EC2 resources only under provider.aws.ec2." } assert { @@ -189,8 +194,8 @@ run "plan_with_pool_enabled" { assert { condition = ( - length(module.compute_ec2_trust_policy) == 1 - && aws_iam_role.runner[0].assume_role_policy == module.compute_ec2_trust_policy[0].assume_role_policy + length(module.compute_aws_ec2_trust_policy) == 1 + && aws_iam_role.runner[0].assume_role_policy == module.compute_aws_ec2_trust_policy[0].assume_role_policy ) error_message = "The common runner role must use the selected EC2 trust-policy submodule output." } @@ -220,7 +225,7 @@ run "plan_with_pool_enabled" { } assert { - condition = !contains(keys(output.provider.ec2), "role_runner") + condition = !contains(keys(output.provider.aws.ec2), "role_runner") error_message = "The common runner role must not be duplicated in the EC2 resource output." } @@ -443,7 +448,7 @@ run "external_runner_role_is_not_managed_by_common" { assert { - condition = output.provider.ec2.launch_template.iam_instance_profile[0].name == "github-actions-runner-profile" + condition = output.provider.aws.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." } } @@ -461,15 +466,17 @@ run "external_runner_role_and_profile_remain_external" { } } compute_provider = { - ec2 = { - vpc_id = "vpc-12345678" - subnet_ids = ["subnet-12345678"] - instance_types = ["m5.large"] - instance_profile = { - name = "external-runner-profile" - } - binaries_syncer = { - enabled = false + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-profile" + } + binaries_syncer = { + enabled = false + } } } } @@ -481,7 +488,7 @@ run "external_runner_role_and_profile_remain_external" { } assert { - condition = output.provider.ec2.launch_template.iam_instance_profile[0].name == "external-runner-profile" + condition = output.provider.aws.ec2.launch_template.iam_instance_profile[0].name == "external-runner-profile" error_message = "The EC2 launch template must use the external instance profile." } } @@ -581,6 +588,22 @@ run "rejects_empty_compute_provider" { expect_failures = [terraform_data.validate_config] } +run "rejects_empty_aws_compute_provider_namespace" { + command = plan + + variables { + compute_provider = { + aws = {} + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + run "job_retry_uses_common_runner_configuration_identity" { command = plan diff --git a/modules/runner-config/tests/tags.tftest.hcl b/modules/runner-config/tests/tags.tftest.hcl index 6d49f89eb8..6c004879ba 100644 --- a/modules/runner-config/tests/tags.tftest.hcl +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -33,23 +33,25 @@ variables { } compute_provider = { - 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" + aws = { + 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 } - kms_key = null - } - binaries_syncer = { - s3 = { - 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" + } } } } diff --git a/modules/runner-config/validations.tf b/modules/runner-config/validations.tf index 3cc1a6d125..61c7d2cd4a 100644 --- a/modules/runner-config/validations.tf +++ b/modules/runner-config/validations.tf @@ -71,10 +71,10 @@ resource "terraform_data" "validate_config" { precondition { condition = length([ - for provider_type, provider_config in var.compute_provider : provider_type + for provider_key, provider_config in local.compute_providers : provider_key if provider_config != null ]) == 1 - error_message = "Exactly one compute-provider block must be set. Supported compute-provider blocks: ec2." + error_message = "Exactly one compute-provider block must be set. Supported compute-provider blocks: aws.ec2." } precondition { diff --git a/modules/runner-config/variables.compute-provider.tf b/modules/runner-config/variables.compute-provider.tf index de3c6e1f2a..6b353930c3 100644 --- a/modules/runner-config/variables.compute-provider.tf +++ b/modules/runner-config/variables.compute-provider.tf @@ -5,244 +5,247 @@ variable "compute_provider" { 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.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 runner-configuration 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 configuration 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. + - `aws`: AWS compute-provider configurations. + - `aws.ec2`: EC2 compute-provider configuration. + - `aws.ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults. + - `aws.ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter. + - `aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. + - `aws.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. + - `aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply. + - `aws.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator. + - `aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply. + - `aws.ec2.vpc_id`: VPC in which runner networking resources are created. + - `aws.ec2.subnet_ids`: Subnets from which scale-up may launch runner instances. + - `aws.ec2.overrides`: Optional resource-name overrides. + - `aws.ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name. + - `aws.ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name. + - `aws.ec2.instance_profile`: Optional externally managed instance profile used by the launch template. + - `aws.ec2.instance_profile.name`: Name of the externally managed instance profile. + - `aws.ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the runner-configuration prefix. + - `aws.ec2.binaries_syncer`: Runner-distribution synchronization configuration. + - `aws.ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3. + - `aws.ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled. + - `aws.ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies. + - `aws.ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI. + - `aws.ec2.binaries_syncer.s3.key`: Object key of the runner distribution. + - `aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template. + - `aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates. + - `aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance. + - `aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption. + - `aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it. + - `aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume. + - `aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume. + - `aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it. + - `aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes. + - `aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB. + - `aws.ec2.block_device_mappings[].volume_type`: EBS volume type. + - `aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances. + - `aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`. + - `aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity. + - `aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type. + - `aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price. + - `aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions. + - `aws.ec2.user_data`: Runner bootstrap user-data configuration. + - `aws.ec2.user_data.enabled`: Enables launch-template user data. + - `aws.ec2.user_data.template`: Optional path to a custom user-data template. + - `aws.ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template. + - `aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template. + - `aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template. + - `aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs. + - `aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access. + - `aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role. + - `aws.ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances. + - `aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow. + - `aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`. + - `aws.ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group. + - `aws.ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults. + - `aws.ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing. + - `aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true. + - `aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent. + - `aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template. + - `aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file. + - `aws.ec2.key_name`: Optional EC2 key-pair name added to the launch template. + - `aws.ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group. + - `aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances. + - `aws.ec2.egress_rules`: Egress rules created on the managed runner security group. + - `aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations. + - `aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations. + - `aws.ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations. + - `aws.ec2.egress_rules[].from_port`: First destination port in the permitted range. + - `aws.ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols. + - `aws.ec2.egress_rules[].security_groups`: Destination security-group IDs. + - `aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. + - `aws.ec2.egress_rules[].to_port`: Last destination port in the permitted range. + - `aws.ec2.egress_rules[].description`: Optional rule description. + - `aws.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. + - `aws.ec2.metadata_options`: Instance Metadata Service configuration in the launch template. + - `aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`. + - `aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. + - `aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. + - `aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. + - `aws.ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`. + - `aws.ec2.cpu_options`: CPU topology and processor-feature configuration. + - `aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance. + - `aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core. + - `aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. + - `aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types. + - `aws.ec2.placement`: EC2 placement configuration for runner instances. + - `aws.ec2.placement.affinity`: Host affinity setting. + - `aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed. + - `aws.ec2.placement.group_id`: Placement-group ID. + - `aws.ec2.placement.group_name`: Placement-group name. + - `aws.ec2.placement.host_id`: Dedicated Host ID. + - `aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement. + - `aws.ec2.placement.spread_domain`: Spread-domain placement value. + - `aws.ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`. + - `aws.ec2.placement.partition_number`: Placement-group partition number. + - `aws.ec2.license_specifications`: License Manager configurations added to the launch template. + - `aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration. + - `aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces. + - `aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure. + - `aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures. + - `aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. EOT type = 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 + aws = optional(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) - kms_key = optional(object({ - arn = string + 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) - }), 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 + 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) - }), {}) - 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) + 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) - 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) + }), {}) }) } From 5df01674d21d12b4de01ae15136fff15d33a58d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:22:51 +0000 Subject: [PATCH 34/49] docs: auto update terraform docs --- modules/compute-providers/aws/ec2/README.md | 10 +++++----- .../compute-providers/aws/ec2/trust-policy/README.md | 10 +++++----- modules/multi-runner/README.md | 12 ++++++------ modules/runner-config/README.md | 12 ++++++------ 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md index 58f3ccd03c..435623f6be 100644 --- a/modules/compute-providers/aws/ec2/README.md +++ b/modules/compute-providers/aws/ec2/README.md @@ -10,14 +10,14 @@ EC2 is the only active compute provider. The parent runner configuration selects ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | @@ -28,7 +28,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 | @@ -58,7 +58,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 by compute-provider resources and policy documents. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 | @@ -72,7 +72,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | | [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | diff --git a/modules/compute-providers/aws/ec2/trust-policy/README.md b/modules/compute-providers/aws/ec2/trust-policy/README.md index 2ead8d1504..f73dc49b9b 100644 --- a/modules/compute-providers/aws/ec2/trust-policy/README.md +++ b/modules/compute-providers/aws/ec2/trust-policy/README.md @@ -6,14 +6,14 @@ This internal submodule builds the EC2 runner-role trust policy independently fr ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | @@ -24,7 +24,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [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.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -32,12 +32,12 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the default EC2 runner-role trust policy. | `string` | `null` | no | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [assume\_role\_policy](#output\_assume\_role\_policy) | EC2 runner-role trust policy with the optional additional trust policy merged into it. | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index d625074bfc..6661aec639 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,7 +167,7 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | @@ -175,7 +175,7 @@ module "multi-runner" { ## 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 | @@ -187,7 +187,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 | @@ -199,7 +199,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -284,7 +284,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-config/README.md b/modules/runner-config/README.md index a33d777edd..c043991d0c 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -69,21 +69,21 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [compute\_aws\_ec2](#module\_compute\_aws\_ec2) | ../compute-providers/aws/ec2 | n/a | | [compute\_aws\_ec2\_trust\_policy](#module\_compute\_aws\_ec2\_trust\_policy) | ../compute-providers/aws/ec2/trust-policy | n/a | | [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | @@ -92,7 +92,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 | @@ -106,7 +106,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.

- `aws`: AWS compute-provider configurations.
- `aws.ec2`: EC2 compute-provider configuration.
- `aws.ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `aws.ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `aws.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.
- `aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `aws.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `aws.ec2.vpc_id`: VPC in which runner networking resources are created.
- `aws.ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `aws.ec2.overrides`: Optional resource-name overrides.
- `aws.ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `aws.ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `aws.ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `aws.ec2.instance_profile.name`: Name of the externally managed instance profile.
- `aws.ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the runner-configuration prefix.
- `aws.ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `aws.ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `aws.ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `aws.ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `aws.ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `aws.ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `aws.ec2.user_data`: Runner bootstrap user-data configuration.
- `aws.ec2.user_data.enabled`: Enables launch-template user data.
- `aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `aws.ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `aws.ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `aws.ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `aws.ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `aws.ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `aws.ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `aws.ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `aws.ec2.egress_rules`: Egress rules created on the managed runner security group.
- `aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `aws.ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `aws.ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `aws.ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `aws.ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `aws.ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `aws.ec2.egress_rules[].description`: Optional rule description.
- `aws.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.
- `aws.ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `aws.ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `aws.ec2.cpu_options`: CPU topology and processor-feature configuration.
- `aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `aws.ec2.placement`: EC2 placement configuration for runner instances.
- `aws.ec2.placement.affinity`: Host affinity setting.
- `aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `aws.ec2.placement.group_id`: Placement-group ID.
- `aws.ec2.placement.group_name`: Placement-group name.
- `aws.ec2.placement.host_id`: Dedicated Host ID.
- `aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `aws.ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `aws.ec2.placement.partition_number`: Placement-group partition number.
- `aws.ec2.license_specifications`: License Manager configurations added to the launch template.
- `aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
aws = optional(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 | @@ -122,7 +122,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. | From 83d25b9d6d25d04cfeb306b6f82c024e7f0dc4b8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Mon, 17 Aug 2026 23:02:24 +0200 Subject: [PATCH 35/49] fix(multi-runner): keep provider topology plan-known --- modules/multi-runner/README.md | 14 ++++---- modules/multi-runner/compute-provider.tf | 12 ++++++- .../config.experimental.translation.tf | 2 ++ modules/multi-runner/main.tf | 8 ++++- modules/multi-runner/runners.experimental.tf | 7 ++-- .../tests/computed-runner-inputs.tftest.hcl | 28 +++++++++++++++ .../fixtures/computed-runner-inputs/README.md | 15 ++++---- .../fixtures/computed-runner-inputs/main.tf | 31 ++++++++++++++++- .../multi-runner/validations.experimental.tf | 34 +++++++++++++++++++ .../multi-runner/variables.experimental.tf | 14 ++++++++ modules/runner-config/README.md | 13 +++---- modules/runner-config/compute-provider.tf | 3 +- modules/runner-config/validations.tf | 8 +++++ .../variables.compute-provider.tf | 12 +++++++ 14 files changed, 175 insertions(+), 26 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 6661aec639..ffd24f3bd6 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,7 +167,7 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | @@ -175,7 +175,7 @@ module "multi-runner" { ## 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 | @@ -187,7 +187,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 | @@ -199,7 +199,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -215,7 +215,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null.
- `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider`: Shared compute-provider defaults grouped first by cloud and then by provider type. Global defaults do not select a provider for any runner configuration.
- `compute_provider.aws`: Shared defaults for AWS compute providers.
- `compute_provider.aws.ec2`: Shared defaults for AWS EC2 runner configurations.
- `compute_provider.aws.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.aws.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.aws.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.aws.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.aws.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.aws.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.aws.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.aws.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.aws.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.aws.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.aws.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.aws.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.aws.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.aws.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.aws.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.aws.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.aws.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.aws.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.aws.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].compute_provider`: Typed compute-provider namespaces. Exactly one nested provider block must be non-null, and that populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply.
- `multi_runner_config[].compute_provider.aws`: AWS compute-provider namespace. The namespace itself does not select a provider.
- `multi_runner_config[].compute_provider.aws.ec2`: AWS EC2-specific configuration. A non-null block selects AWS EC2 for this runner configuration.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.aws.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.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.
- `multi_runner_config[].compute_provider.aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.aws.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.aws.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.aws.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.aws.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.aws.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.aws.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.aws.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.aws.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].compute_provider.aws.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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

compute_provider = object({
aws = optional(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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
}), {})
})

})), {})
})
| `{}` | 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null.
- `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider`: Shared compute-provider defaults grouped first by cloud and then by provider type. Global defaults do not select a provider for any runner configuration.
- `compute_provider.selections`: Optional plan-shaping map keyed by runner-configuration key. Each entry identifies the namespace and type of the configuration's selected compute-provider block. The default is null, which discovers selections from the typed provider blocks. Set this map when unrelated apply-time values make that discovery unknown; its keys and values must be known during planning and cover every runner configuration exactly once.
- `compute_provider.selections[].namespace`: Compute-provider namespace. The only currently supported value is `aws`.
- `compute_provider.selections[].type`: Compute-provider type within the namespace. The only currently supported value is `ec2`.
- `compute_provider.aws`: Shared defaults for AWS compute providers.
- `compute_provider.aws.ec2`: Shared defaults for AWS EC2 runner configurations.
- `compute_provider.aws.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.aws.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.aws.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.aws.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.aws.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.aws.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.aws.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.aws.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.aws.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.aws.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.aws.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.aws.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.aws.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.aws.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.aws.ec2.runner_binaries.targets`: Optional plan-shaping map of shared runner-distribution targets, keyed by `_`. The default is null, which discovers enabled targets from runner configurations. Set this map when unrelated apply-time values make that discovery unknown. An empty map creates no shared binary syncers; every enabled runner platform must have a corresponding entry.
- `compute_provider.aws.ec2.runner_binaries.targets[].os`: Runner operating system for the target. Valid values are `linux`, `osx`, and `windows`.
- `compute_provider.aws.ec2.runner_binaries.targets[].architecture`: Runner distribution architecture for the target. Valid values are `x64` and `arm64`.
- `compute_provider.aws.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.aws.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.aws.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.aws.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.aws.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].compute_provider`: Typed compute-provider namespaces. Exactly one nested provider block must be non-null, and that populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply.
- `multi_runner_config[].compute_provider.aws`: AWS compute-provider namespace. The namespace itself does not select a provider.
- `multi_runner_config[].compute_provider.aws.ec2`: AWS EC2-specific configuration. A non-null block selects AWS EC2 for this runner configuration.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.aws.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.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.
- `multi_runner_config[].compute_provider.aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.aws.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.aws.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.aws.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.aws.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.aws.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.aws.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.aws.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.aws.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].compute_provider.aws.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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
targets = optional(map(object({
os = string
architecture = string
})), null)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

compute_provider = object({
aws = optional(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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
}), {})
})

})), {})
})
| `{}` | 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
}))
})
| `null` | no | @@ -284,7 +284,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/compute-provider.tf b/modules/multi-runner/compute-provider.tf index 6f4531aa1f..98efaa5bff 100644 --- a/modules/multi-runner/compute-provider.tf +++ b/modules/multi-runner/compute-provider.tf @@ -1,5 +1,7 @@ locals { - compute_provider_selections = { + configured_compute_provider_selections = local.use_multi_runner_config_v2 ? var.experimental.compute_provider.selections : null + + discovered_compute_provider_selections = { for runner_key, runner_config in local.translated_experimental_base.multi_runner_config : runner_key => try(one(flatten([ for provider_namespace, provider_configs in runner_config.compute_provider : [ for provider_type, provider_config in provider_configs : { @@ -12,6 +14,14 @@ locals { ])), null) } + compute_provider_selections = local.configured_compute_provider_selections != null ? { + for runner_key, selection in local.configured_compute_provider_selections : runner_key => { + namespace = selection.namespace + type = selection.type + key = "${selection.namespace}_${selection.type}" + } + } : local.discovered_compute_provider_selections + compute_provider_keys = { for runner_key, selection in local.compute_provider_selections : runner_key => try(selection.key, null) diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 20708f2767..8423f7aefd 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -206,6 +206,7 @@ locals { } compute_provider = { + selections = null aws = { ec2 = { vpc_id = var.vpc_id @@ -259,6 +260,7 @@ locals { } runner_binaries = { enabled = true + targets = null s3 = { encryption = { enabled = var.runner_binaries_s3_sse_configuration != null diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index bf57b138ba..1ccae8f027 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -26,7 +26,13 @@ locals { } if config.compute_provider.aws.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 } + configured_runner_binary_targets = local.use_multi_runner_config_v2 ? var.experimental.compute_provider.aws.ec2.runner_binaries.targets : null + unique_os_and_arch = local.configured_runner_binary_targets != null ? { + for key, target in local.configured_runner_binary_targets : key => { + os_type = target.os + architecture = target.architecture + } + } : { for _, v in local.tmp_distinct_list_unique_os_and_arch : "${v.os_type}_${v.architecture}" => v } } resource "random_string" "random" { diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index 25108dcda8..b889c6a467 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -32,7 +32,8 @@ module "runner_configs" { job_retry = each.value.orchestration_provider.webhook.job_retry } } - ssm = each.value.ssm - observability = each.value.observability - compute_provider = each.value.compute_provider + ssm = each.value.ssm + observability = each.value.observability + compute_provider = each.value.compute_provider + compute_provider_key = try(local.compute_provider_keys[each.key], null) } diff --git a/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl b/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl index 1a9a59df1d..63274734ac 100644 --- a/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl +++ b/modules/multi-runner/tests/computed-runner-inputs.tftest.hcl @@ -65,3 +65,31 @@ run "computed_lane_values_keep_binary_syncer_instances_plannable" { error_message = "A runner configuration with the binary syncer disabled must not create a binary-syncer module instance." } } + +run "computed_lane_values_keep_enabled_binary_syncer_instances_plannable" { + command = plan + + module { + source = "./tests/fixtures/computed-runner-inputs" + } + + variables { + enable_runner_binaries_syncer = true + runner_binary_targets = { + linux_x64 = { + os = "linux" + architecture = "x64" + } + } + } + + assert { + condition = output.runner_config_keys == ["linux"] + error_message = "The explicit provider selection must keep runner-config dispatch plannable when unrelated lane values are known only after apply." + } + + assert { + condition = output.binaries_syncer_keys == ["linux_x64"] + error_message = "The explicit runner-binary target must create the enabled binary-syncer instance with a plan-known key." + } +} diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 5e0e532bc0..7e9a0cdd30 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,36 +2,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 | -|------|--------|---------| +| ---- | ------ | ------- | | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs -No inputs. +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | n/a | `bool` | `false` | no | +| [runner\_binary\_targets](#input\_runner\_binary\_targets) | n/a |
map(object({
os = string
architecture = string
}))
| `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | | [runner\_config\_keys](#output\_runner\_config\_keys) | n/a | diff --git a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf index df3d9f5ac0..2c0d644bc1 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/main.tf @@ -4,6 +4,19 @@ resource "random_id" "managed_policy" { byte_length = 4 } +variable "enable_runner_binaries_syncer" { + type = bool + default = false +} + +variable "runner_binary_targets" { + type = map(object({ + os = string + architecture = string + })) + default = {} +} + module "multi_runner" { source = "../../.." @@ -62,10 +75,26 @@ module "multi_runner" { } compute_provider = { + selections = { + linux = { + namespace = "aws" + type = "ec2" + } + } aws = { ec2 = { vpc_id = "vpc-nested-12345678" subnet_ids = ["subnet-nested-12345678"] + runner_binaries = { + targets = var.runner_binary_targets + syncer = { + artifact = { + s3 = { + key = "runner-binaries-syncer.zip" + } + } + } + } } } } @@ -110,7 +139,7 @@ module "multi_runner" { ec2 = { instance_types = ["m5.large"] binaries_syncer = { - enabled = false + enabled = var.enable_runner_binaries_syncer } } } diff --git a/modules/multi-runner/validations.experimental.tf b/modules/multi-runner/validations.experimental.tf index 1140270ccb..2f4d4e061e 100644 --- a/modules/multi-runner/validations.experimental.tf +++ b/modules/multi-runner/validations.experimental.tf @@ -68,6 +68,30 @@ resource "terraform_data" "validate_experimental" { error_message = "Each experimental runner configuration must set exactly one compute-provider block. Supported compute-provider blocks: aws.ec2." } + precondition { + condition = var.experimental.compute_provider.selections == null ? true : ( + toset(keys(var.experimental.compute_provider.selections)) == + toset(keys(var.experimental.multi_runner_config)) + ) + error_message = "experimental.compute_provider.selections must contain exactly the same keys as experimental.multi_runner_config when set." + } + + precondition { + condition = var.experimental.compute_provider.selections == null ? true : alltrue([ + for selection in values(var.experimental.compute_provider.selections) : + selection.namespace == "aws" && selection.type == "ec2" + ]) + error_message = "experimental.compute_provider.selections supports only namespace = aws and type = ec2." + } + + precondition { + condition = var.experimental.compute_provider.selections == null ? true : alltrue([ + for runner_key, selection in var.experimental.compute_provider.selections : + try(var.experimental.multi_runner_config[runner_key].compute_provider[selection.namespace][selection.type] != null, false) + ]) + error_message = "Each experimental.compute_provider.selections entry must identify the non-null typed compute-provider block selected by the matching runner configuration." + } + precondition { condition = alltrue([ for runner_config in values(var.experimental.multi_runner_config) : @@ -262,6 +286,16 @@ resource "terraform_data" "validate_experimental" { error_message = "experimental.compute_provider.aws.ec2.runner_binaries.syncer.schedule.state must be DISABLED, ENABLED, or ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS." } + precondition { + condition = var.experimental.compute_provider.aws.ec2.runner_binaries.targets == null ? true : alltrue([ + for key, target in var.experimental.compute_provider.aws.ec2.runner_binaries.targets : + key == "${target.os}_${target.architecture}" && + contains(["linux", "osx", "windows"], target.os) && + contains(["x64", "arm64"], target.architecture) + ]) + error_message = "experimental.compute_provider.aws.ec2.runner_binaries.targets keys must use _ and values must use a supported runner OS and architecture." + } + precondition { condition = !local.use_multi_runner_config_v2 || contains( ["first", "random", "all"], diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index 34990b4dbb..ae19eab604 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -170,6 +170,9 @@ variable "experimental" { - `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`. - `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`. - `compute_provider`: Shared compute-provider defaults grouped first by cloud and then by provider type. Global defaults do not select a provider for any runner configuration. + - `compute_provider.selections`: Optional plan-shaping map keyed by runner-configuration key. Each entry identifies the namespace and type of the configuration's selected compute-provider block. The default is null, which discovers selections from the typed provider blocks. Set this map when unrelated apply-time values make that discovery unknown; its keys and values must be known during planning and cover every runner configuration exactly once. + - `compute_provider.selections[].namespace`: Compute-provider namespace. The only currently supported value is `aws`. + - `compute_provider.selections[].type`: Compute-provider type within the namespace. The only currently supported value is `ec2`. - `compute_provider.aws`: Shared defaults for AWS compute providers. - `compute_provider.aws.ec2`: Shared defaults for AWS EC2 runner configurations. - `compute_provider.aws.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally. @@ -223,6 +226,9 @@ variable "experimental" { - `compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module. - `compute_provider.aws.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair. - `compute_provider.aws.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.aws.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances. + - `compute_provider.aws.ec2.runner_binaries.targets`: Optional plan-shaping map of shared runner-distribution targets, keyed by `_`. The default is null, which discovers enabled targets from runner configurations. Set this map when unrelated apply-time values make that discovery unknown. An empty map creates no shared binary syncers; every enabled runner platform must have a corresponding entry. + - `compute_provider.aws.ec2.runner_binaries.targets[].os`: Runner operating system for the target. Valid values are `linux`, `osx`, and `windows`. + - `compute_provider.aws.ec2.runner_binaries.targets[].architecture`: Runner distribution architecture for the target. Valid values are `x64` and `arm64`. - `compute_provider.aws.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket. - `compute_provider.aws.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket. - `compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false. @@ -711,6 +717,10 @@ variable "experimental" { }), {}) compute_provider = optional(object({ + selections = optional(map(object({ + namespace = string + type = string + })), null) aws = optional(object({ ec2 = optional(object({ vpc_id = optional(string, null) @@ -797,6 +807,10 @@ variable "experimental" { }), {}) runner_binaries = optional(object({ enabled = optional(bool, true) + targets = optional(map(object({ + os = string + architecture = string + })), null) s3 = optional(object({ encryption = optional(object({ enabled = optional(bool, true) diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index c043991d0c..be3cb0e478 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -69,21 +69,21 @@ yarn run dist ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [compute\_aws\_ec2](#module\_compute\_aws\_ec2) | ../compute-providers/aws/ec2 | n/a | | [compute\_aws\_ec2\_trust\_policy](#module\_compute\_aws\_ec2\_trust\_policy) | ../compute-providers/aws/ec2/trust-policy | n/a | | [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | @@ -92,7 +92,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 | @@ -106,10 +106,11 @@ 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.

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

- `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the 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.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `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({
artifact = optional(object({
s3 = optional(object({
bucket = 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), {})
principals = optional(list(object({
type = string
identifiers = list(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 | @@ -122,7 +123,7 @@ yarn run dist ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. | diff --git a/modules/runner-config/compute-provider.tf b/modules/runner-config/compute-provider.tf index d9bd644d6b..bffc43b814 100644 --- a/modules/runner-config/compute-provider.tf +++ b/modules/runner-config/compute-provider.tf @@ -3,10 +3,11 @@ locals { aws_ec2 = var.compute_provider.aws.ec2 } - provider_key = one([ + discovered_provider_key = one([ for provider_key, provider_config in local.compute_providers : provider_key if provider_config != null ]) + provider_key = var.compute_provider_key != null ? var.compute_provider_key : local.discovered_provider_key provider_types = { aws_ec2 = "ec2" diff --git a/modules/runner-config/validations.tf b/modules/runner-config/validations.tf index 61c7d2cd4a..f510bf0422 100644 --- a/modules/runner-config/validations.tf +++ b/modules/runner-config/validations.tf @@ -77,6 +77,14 @@ resource "terraform_data" "validate_config" { error_message = "Exactly one compute-provider block must be set. Supported compute-provider blocks: aws.ec2." } + precondition { + condition = var.compute_provider_key == null || try( + local.compute_providers[var.compute_provider_key] != null, + false, + ) + error_message = "compute_provider_key must identify the non-null typed compute-provider block." + } + precondition { condition = length([ for provider_name, provider_config in var.orchestration_provider : provider_name diff --git a/modules/runner-config/variables.compute-provider.tf b/modules/runner-config/variables.compute-provider.tf index 6b353930c3..2b63be5703 100644 --- a/modules/runner-config/variables.compute-provider.tf +++ b/modules/runner-config/variables.compute-provider.tf @@ -1,3 +1,15 @@ +# Optional plan-known dispatch key supplied by the multi-runner topology layer. +variable "compute_provider_key" { + description = "Optional plan-known compute-provider dispatch key. Null discovers the key from the exactly one populated compute_provider block." + type = string + default = null + + validation { + condition = var.compute_provider_key == null ? true : contains(["aws_ec2"], var.compute_provider_key) + error_message = "compute_provider_key must be null or aws_ec2." + } +} + # Typed compute-provider input boundary between the common control plane and compute implementations. variable "compute_provider" { description = <<-EOT From 127c8f89ec602e7c036a45999fe5b45fe3e0a160 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:03:36 +0000 Subject: [PATCH 36/49] docs: auto update terraform docs --- modules/multi-runner/README.md | 12 ++++++------ .../tests/fixtures/computed-runner-inputs/README.md | 12 ++++++------ modules/runner-config/README.md | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index ffd24f3bd6..6f01f1b345 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,7 +167,7 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | @@ -175,7 +175,7 @@ module "multi-runner" { ## 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 | @@ -187,7 +187,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 | @@ -199,7 +199,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -284,7 +284,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/tests/fixtures/computed-runner-inputs/README.md b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md index 7e9a0cdd30..f823a09d44 100644 --- a/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md +++ b/modules/multi-runner/tests/fixtures/computed-runner-inputs/README.md @@ -2,39 +2,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 | -| ---- | ------ | ------- | +|------|--------|---------| | [multi\_runner](#module\_multi\_runner) | ../../.. | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [random_id.managed_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | n/a | `bool` | `false` | no | | [runner\_binary\_targets](#input\_runner\_binary\_targets) | n/a |
map(object({
os = string
architecture = string
}))
| `{}` | no | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_keys](#output\_binaries\_syncer\_keys) | n/a | | [runner\_config\_keys](#output\_runner\_config\_keys) | n/a | diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index be3cb0e478..cff1ce73c7 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -69,21 +69,21 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [compute\_aws\_ec2](#module\_compute\_aws\_ec2) | ../compute-providers/aws/ec2 | n/a | | [compute\_aws\_ec2\_trust\_policy](#module\_compute\_aws\_ec2\_trust\_policy) | ../compute-providers/aws/ec2/trust-policy | n/a | | [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | @@ -92,7 +92,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 | @@ -106,7 +106,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.

- `aws`: AWS compute-provider configurations.
- `aws.ec2`: EC2 compute-provider configuration.
- `aws.ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `aws.ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `aws.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.
- `aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `aws.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `aws.ec2.vpc_id`: VPC in which runner networking resources are created.
- `aws.ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `aws.ec2.overrides`: Optional resource-name overrides.
- `aws.ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `aws.ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `aws.ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `aws.ec2.instance_profile.name`: Name of the externally managed instance profile.
- `aws.ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the runner-configuration prefix.
- `aws.ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `aws.ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `aws.ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `aws.ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `aws.ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `aws.ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `aws.ec2.user_data`: Runner bootstrap user-data configuration.
- `aws.ec2.user_data.enabled`: Enables launch-template user data.
- `aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `aws.ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `aws.ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `aws.ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `aws.ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `aws.ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `aws.ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `aws.ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `aws.ec2.egress_rules`: Egress rules created on the managed runner security group.
- `aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `aws.ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `aws.ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `aws.ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `aws.ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `aws.ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `aws.ec2.egress_rules[].description`: Optional rule description.
- `aws.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.
- `aws.ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `aws.ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `aws.ec2.cpu_options`: CPU topology and processor-feature configuration.
- `aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `aws.ec2.placement`: EC2 placement configuration for runner instances.
- `aws.ec2.placement.affinity`: Host affinity setting.
- `aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `aws.ec2.placement.group_id`: Placement-group ID.
- `aws.ec2.placement.group_name`: Placement-group name.
- `aws.ec2.placement.host_id`: Dedicated Host ID.
- `aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `aws.ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `aws.ec2.placement.partition_number`: Placement-group partition number.
- `aws.ec2.license_specifications`: License Manager configurations added to the launch template.
- `aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
aws = optional(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 | @@ -123,7 +123,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. | From e8a3cfb717e47eaa49b10cd067a46c6a46fdcc57 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 18 Aug 2026 22:24:00 +0200 Subject: [PATCH 37/49] refactor(storage): extract runner config store --- lambdas/functions/control-plane/package.json | 1 + .../functions/control-plane/src/modules.d.ts | 1 - .../src/pool/pool-contract.test.ts | 3 + .../control-plane/src/pool/pool.test.ts | 13 +++ .../functions/control-plane/src/pool/pool.ts | 4 +- .../src/scale-runners/github-runner.ts | 50 ++++++----- .../scale-runners/scale-up-contract.test.ts | 3 + .../src/scale-runners/scale-up.test.ts | 18 +++- .../src/scale-runners/scale-up.ts | 4 +- .../ec2/src/control-plane/runner-config.ts | 2 +- .../ec2/src/control-plane/scale-up.test.ts | 3 +- lambdas/libs/compute-providers/core/index.ts | 3 +- .../aws/ssm/environment.d.ts | 10 +++ .../aws/ssm/parameter-store-tags.ts | 42 +++++++++ .../aws/ssm/runner-config-store.test.ts | 88 +++++++++++++++++++ .../aws/ssm/runner-config-store.ts | 37 ++++++++ lambdas/libs/storage-providers/core/index.ts | 14 +++ .../libs/storage-providers/environment.d.ts | 9 ++ lambdas/libs/storage-providers/index.ts | 2 + lambdas/libs/storage-providers/package.json | 29 ++++++ .../storage-providers/runner-config.test.ts | 84 ++++++++++++++++++ .../libs/storage-providers/runner-config.ts | 46 ++++++++++ lambdas/libs/storage-providers/tsconfig.json | 5 ++ .../libs/storage-providers/vitest.config.ts | 14 +++ lambdas/yarn.lock | 9 ++ 25 files changed, 463 insertions(+), 31 deletions(-) create mode 100644 lambdas/libs/storage-providers/aws/ssm/environment.d.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts create mode 100644 lambdas/libs/storage-providers/core/index.ts create mode 100644 lambdas/libs/storage-providers/environment.d.ts create mode 100644 lambdas/libs/storage-providers/index.ts create mode 100644 lambdas/libs/storage-providers/package.json create mode 100644 lambdas/libs/storage-providers/runner-config.test.ts create mode 100644 lambdas/libs/storage-providers/runner-config.ts create mode 100644 lambdas/libs/storage-providers/tsconfig.json create mode 100644 lambdas/libs/storage-providers/vitest.config.ts diff --git a/lambdas/functions/control-plane/package.json b/lambdas/functions/control-plane/package.json index dc74e770a9..0f443fc849 100644 --- a/lambdas/functions/control-plane/package.json +++ b/lambdas/functions/control-plane/package.json @@ -33,6 +33,7 @@ "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", "@aws-github-runner/compute-providers": "*", + "@aws-github-runner/storage-providers": "*", "@aws-lambda-powertools/parameters": "^2.31.0", "@aws-sdk/client-ec2": "^3.1009.0", "@aws-sdk/client-sqs": "^3.1009.0", diff --git a/lambdas/functions/control-plane/src/modules.d.ts b/lambdas/functions/control-plane/src/modules.d.ts index d5157ccb37..84b0d23a02 100644 --- a/lambdas/functions/control-plane/src/modules.d.ts +++ b/lambdas/functions/control-plane/src/modules.d.ts @@ -18,7 +18,6 @@ declare namespace NodeJS { RUNNER_OWNER: string; COMPUTE_PROVIDER_TYPE?: string; SCALE_DOWN_CONFIG: string; - SSM_TOKEN_PATH: string; SSM_CLEANUP_CONFIG: string; SUBNET_IDS: string; INSTANCE_TYPES: string; 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 e519e412e4..28e38c6a77 100644 --- a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts @@ -1,5 +1,6 @@ import type { Octokit } from '@octokit/rest'; import type { ComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { beforeEach, vi } from 'vitest'; import { definePoolContractTests } from '../test/compute-provider-contracts/pool'; @@ -48,6 +49,8 @@ const computeProviders = providerTypes.map((type) => ({ beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; + process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; + resetRunnerConfigStore(); mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); mockedInstallationAuth.mockResolvedValue({ diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index ee41d77b41..d99a7c15f7 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -4,6 +4,7 @@ import * as nock from 'nock'; import { createRunners } from '@aws-github-runner/compute-providers/aws/ec2/control-plane/runner-config'; import { listEC2Runners } from '@aws-github-runner/compute-providers/aws/ec2/control-plane/runners'; +import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; import * as ghAuth from '../github/auth'; import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner'; import { adjust } from './pool'; @@ -134,6 +135,7 @@ beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); process.env = { ...cleanEnv }; + resetRunnerConfigStore(); process.env.GITHUB_APP_KEY_BASE64 = 'TEST_CERTIFICATE_DATA'; process.env.GITHUB_APP_ID = '1337'; process.env.GITHUB_APP_CLIENT_ID = 'TEST_CLIENT_ID'; @@ -253,6 +255,17 @@ describe('Test simple pool.', () => { expect(mockListRunners).not.toHaveBeenCalled(); }); + it('Rejects an unsupported runner config store before GitHub or runner lookups.', async () => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'unsupported-provider'; + + await expect(adjust({ poolSize: 10, type: 'ec2' })).rejects.toThrow( + "Unsupported runner config storage provider 'unsupported-provider'", + ); + + expect(mockedAppAuth).not.toHaveBeenCalled(); + expect(mockListRunners).not.toHaveBeenCalled(); + }); + it('Should not top up if pool size is reached.', async () => { await adjust({ poolSize: 1, type: 'ec2' }); expect(createRunners).not.toHaveBeenCalled(); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index da5d2ee9b1..ab7f5a7c94 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -1,6 +1,7 @@ import { Octokit } from '@octokit/rest'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { getRunnerConfigStore } from '@aws-github-runner/storage-providers'; import yn from 'yn'; import { @@ -31,7 +32,6 @@ export async function adjust(event: PoolEvent): Promise { const runnerGroup = process.env.RUNNER_GROUP_NAME || ''; const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || ''; const environment = process.env.ENVIRONMENT; - const ssmTokenPath = process.env.SSM_TOKEN_PATH; const ssmConfigPath = process.env.SSM_CONFIG_PATH || ''; const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false }); const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral }); @@ -41,6 +41,7 @@ export async function adjust(event: PoolEvent): Promise { process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== '' ? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS) : []; + getRunnerConfigStore(); // -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited // when unset so the pool keeps its previous behavior on stacks that do not provide the variable. const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1'); @@ -103,7 +104,6 @@ export async function adjust(event: PoolEvent): Promise { runnerNamePrefix, runnerType: 'Org', disableAutoUpdate: disableAutoUpdate, - ssmTokenPath, ssmConfigPath, ssmParameterStoreTags, }, diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 745c66770a..79c78608dc 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -1,5 +1,10 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { getParameter, putParameter } from '@aws-github-runner/aws-ssm-util'; +import { + getRunnerConfigStore, + type RunnerConfigMetadataTag, + type RunnerConfigStore, +} from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; import { getStoredInstallationId } from '../github/auth'; @@ -14,7 +19,7 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { - getSsmParameterTags?: (runnerId: string) => { Key: string; Value: string }[]; + getRunnerConfigMetadataTags?: (runnerId: string) => RunnerConfigMetadataTag[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; } @@ -250,18 +255,20 @@ export async function createStartRunnerConfig( ghClient: Octokit, options: StartRunnerConfigOptions = {}, ): Promise { + const runnerConfigStore = getRunnerConfigStore(); if (githubRunnerConfig.enableJitConfig && githubRunnerConfig.ephemeral) { - return await createJitConfig(githubRunnerConfig, runnerIds, ghClient, options); + return await createJitConfig(githubRunnerConfig, runnerIds, ghClient, runnerConfigStore, options); } else { - return await createRegistrationTokenConfig(githubRunnerConfig, runnerIds, ghClient, options); + return await createRegistrationTokenConfig(githubRunnerConfig, runnerIds, ghClient, runnerConfigStore, options); } } -function addDelay(runnerIds: string[]) { +function addDelay(runnerIds: string[], runnerConfigStore: RunnerConfigStore) { const delay = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const ssmParameterStoreMaxThroughput = 40; - const isDelay = runnerIds.length >= ssmParameterStoreMaxThroughput; - return { isDelay, delay }; + const maxWritesPerSecond = runnerConfigStore.maxWritesPerSecond; + const isDelay = maxWritesPerSecond !== undefined && runnerIds.length >= maxWritesPerSecond; + const delayMilliseconds = maxWritesPerSecond === undefined ? 0 : 1000 / maxWritesPerSecond; + return { isDelay, delay, delayMilliseconds }; } /** @@ -273,9 +280,10 @@ async function createRegistrationTokenConfig( githubRunnerConfig: CreateGitHubRunnerConfig, runnerIds: string[], ghClient: Octokit, + runnerConfigStore: RunnerConfigStore, options: StartRunnerConfigOptions, ): Promise { - const { isDelay, delay } = addDelay(runnerIds); + const { isDelay, delay, delayMilliseconds } = addDelay(runnerIds, runnerConfigStore); const token = await getGithubRunnerRegistrationToken(githubRunnerConfig, ghClient); const runnerServiceConfig = generateRunnerServiceConfig(githubRunnerConfig, token); @@ -284,12 +292,13 @@ async function createRegistrationTokenConfig( }); for (const runnerId of runnerIds) { - await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerServiceConfig.join(' '), true, { - tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags], - }); + await runnerConfigStore.create( + { runnerId, value: runnerServiceConfig.join(' ') }, + { metadataTags: options.getRunnerConfigMetadataTags?.(runnerId) }, + ); if (isDelay) { - // Delay to prevent AWS ssm rate limits by being within the max throughput limit - await delay(25); + // Delay to stay within the selected store's maximum write throughput. + await delay(delayMilliseconds); } } @@ -306,10 +315,11 @@ async function createJitConfig( githubRunnerConfig: CreateGitHubRunnerConfig, runnerIds: string[], ghClient: Octokit, + runnerConfigStore: RunnerConfigStore, options: StartRunnerConfigOptions, ): Promise { const runnerGroupId = await getRunnerGroupId(githubRunnerConfig, ghClient); - const { isDelay, delay } = addDelay(runnerIds); + const { isDelay, delay, delayMilliseconds } = addDelay(runnerIds, runnerConfigStore); const runnerLabels = githubRunnerConfig.runnerLabels.split(','); const failedRunnerIds: string[] = []; @@ -347,16 +357,16 @@ async function createJitConfig( runnerLabels, }); - // store jit config in ssm parameter store logger.debug('Runner JIT config for ephemeral runner generated.', { instance: runnerId, }); - await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerConfig.data.encoded_jit_config, true, { - tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags], - }); + await runnerConfigStore.create( + { runnerId, value: runnerConfig.data.encoded_jit_config }, + { metadataTags: options.getRunnerConfigMetadataTags?.(runnerId) }, + ); if (isDelay) { - // Delay to prevent AWS ssm rate limits by being within the max throughput limit - await delay(25); + // Delay to stay within the selected store's maximum write throughput. + await delay(delayMilliseconds); } } catch (error) { failedRunnerIds.push(runnerId); 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 3c1a0362bb..5d190f3e5f 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 @@ -1,4 +1,5 @@ import type { Octokit } from '@octokit/rest'; +import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { beforeEach, vi } from 'vitest'; import { providerTypes } from '../test/compute-provider-contracts/provider-types'; @@ -56,6 +57,8 @@ const computeProviders = providerTypes.map((type) => ({ beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; + process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; + resetRunnerConfigStore(); mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); mockedInstallationAuth.mockResolvedValue({ diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 9df79ceac1..275e163bf8 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -17,6 +17,7 @@ import type { ScaleUpComputeProvider, } from './types'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Octokit } from '@octokit/rest'; @@ -147,6 +148,7 @@ function setDefaults() { process.env.GITHUB_APP_CLIENT_SECRET = 'TEST_CLIENT_SECRET'; process.env.RUNNERS_MAXIMUM_COUNT = '3'; process.env.ENVIRONMENT = EXPECTED_RUNNER_PARAMS.environment; + process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; } async function createTestProviderRunners(input: CreateScaleUpRunnersInput): Promise { @@ -168,7 +170,7 @@ async function createTestProviderRunners(input: CreateScaleUpRunnersInput [{ Key: 'RunnerId', Value: runnerId }], + getRunnerConfigMetadataTags: (runnerId) => [{ key: 'RunnerId', value: runnerId }], }, ); } catch { @@ -187,6 +189,7 @@ beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); setDefaults(); + resetRunnerConfigStore(); defaultSSMGetParameterMockImpl(); defaultOctokitMockImpl(); @@ -2166,6 +2169,19 @@ describe('compute provider selection', () => { }); }); +describe('runner config store preflight', () => { + it('rejects an unsupported store before resolving compute or GitHub providers', async () => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'unsupported-provider'; + + await expect(scaleUpModule.scaleUp(TEST_DATA)).rejects.toThrow( + "Unsupported runner config storage provider 'unsupported-provider'", + ); + + expect(mockedResolveCapability).not.toHaveBeenCalled(); + expect(mockedAppAuth).not.toHaveBeenCalled(); + }); +}); + describe('Multi-app round-robin', () => { const mockedGetAppCount = vi.mocked(ghAuth.getAppCount); const mockedGetStoredInstallationId = vi.mocked(ghAuth.getStoredInstallationId); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index 44d522a1f0..a33a8c9705 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -1,5 +1,6 @@ import { addPersistentContextToChildLogger, createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { getRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; import yn from 'yn'; @@ -80,7 +81,6 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise { function createEc2StartRunnerConfigOptions(): StartRunnerConfigOptions { return { - getSsmParameterTags: (instanceId) => [{ Key: 'InstanceId', Value: instanceId }], + getRunnerConfigMetadataTags: (instanceId) => [{ key: 'InstanceId', value: instanceId }], onJitConfigCreated: async (instanceId, metadata) => await tagEc2RunnerMetadata(instanceId, metadata), }; } diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts index 0c6bac69f4..7695839ba4 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts @@ -51,7 +51,6 @@ function runnerConfig(overrides: Partial = {}): Create runnerOwner, runnerType: 'Org', disableAutoUpdate: false, - ssmTokenPath: '/github-action-runners/default/runners/config', ssmConfigPath: '/github-action-runners/default/runners/config', ssmParameterStoreTags: [], ...overrides, @@ -182,7 +181,7 @@ describe('scaleUp with GHES', () => { { Key: 'ghr:runner_labels', Value: 'label1,label2' }, ]); const [, , , options] = mockCreateStartRunnerConfig.mock.calls[0]; - expect(options?.getSsmParameterTags?.('i-12345')).toEqual([{ Key: 'InstanceId', Value: 'i-12345' }]); + expect(options?.getRunnerConfigMetadataTags?.('i-12345')).toEqual([{ key: 'InstanceId', value: 'i-12345' }]); }); it('chunks comma-joined GitHub runner labels by the EC2 tag value max length', async () => { diff --git a/lambdas/libs/compute-providers/core/index.ts b/lambdas/libs/compute-providers/core/index.ts index 2b5f937f36..9125dd9b90 100644 --- a/lambdas/libs/compute-providers/core/index.ts +++ b/lambdas/libs/compute-providers/core/index.ts @@ -21,7 +21,6 @@ export interface CreateGitHubRunnerConfig { runnerOwner: string; runnerType: RunnerType; disableAutoUpdate: boolean; - ssmTokenPath: string; ssmConfigPath: string; ssmParameterStoreTags: { Key: string; Value: string }[]; } @@ -32,7 +31,7 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { - getSsmParameterTags?: (runnerId: string) => { Key: string; Value: string }[]; + getRunnerConfigMetadataTags?: (runnerId: string) => { key: string; value: string }[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; } diff --git a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts new file mode 100644 index 0000000000..c6dd725742 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts @@ -0,0 +1,10 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + SSM_PARAMETER_STORE_TAGS?: string; + SSM_TOKEN_PATH?: string; + } + } +} diff --git a/lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts b/lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts new file mode 100644 index 0000000000..d35150e10a --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts @@ -0,0 +1,42 @@ +interface SsmParameterStoreTag { + Key: string; + Value: string; +} + +export function loadSsmParameterStoreTagsFromEnvironment(): SsmParameterStoreTag[] { + return process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== '' + ? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS) + : []; +} + +function validateSsmParameterStoreTags(tagsJson: string): SsmParameterStoreTag[] { + try { + const tags: unknown = JSON.parse(tagsJson); + + if (!Array.isArray(tags)) { + throw new Error('Tags must be an array'); + } + + if (tags.length === 0) { + return []; + } + + tags.forEach((tag: unknown, index: number) => { + if (typeof tag !== 'object' || tag === null) { + throw new Error(`Tag at index ${index} must be an object`); + } + + const candidate = tag as Record; + if (!candidate.Key || typeof candidate.Key !== 'string' || candidate.Key.trim() === '') { + throw new Error(`Tag at index ${index} has missing or invalid 'Key' property`); + } + if (!Object.prototype.hasOwnProperty.call(candidate, 'Value') || typeof candidate.Value !== 'string') { + throw new Error(`Tag at index ${index} has missing or invalid 'Value' property`); + } + }); + + return tags as SsmParameterStoreTag[]; + } catch (error) { + throw new Error(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${(error as Error).message}`); + } +} diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts new file mode 100644 index 0000000000..eaacff2718 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts @@ -0,0 +1,88 @@ +import { putParameter } from '@aws-github-runner/aws-ssm-util'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmRunnerConfigStore } from './runner-config-store'; + +vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + putParameter: vi.fn(), +})); + +const putParameterMock = vi.mocked(putParameter); +const cleanEnv = process.env; + +describe('aws_ssm runner config store', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.SSM_PARAMETER_STORE_TAGS; + process.env.SSM_TOKEN_PATH = '/runner/tokens'; + }); + + it('creates a secure parameter at the legacy path with metadata tags before configured tags', async () => { + process.env.SSM_PARAMETER_STORE_TAGS = JSON.stringify([ + { Key: 'Environment', Value: 'test' }, + { Key: 'Team', Value: 'actions' }, + ]); + const store = createAwsSsmRunnerConfigStore(); + + await store.create( + { runnerId: 'i-123', value: 'encoded-jit-config' }, + { metadataTags: [{ key: 'InstanceId', value: 'i-123' }] }, + ); + + expect(store.maxWritesPerSecond).toBe(40); + expect(putParameterMock).toHaveBeenCalledWith('/runner/tokens/i-123', 'encoded-jit-config', true, { + tags: [ + { Key: 'InstanceId', Value: 'i-123' }, + { Key: 'Environment', Value: 'test' }, + { Key: 'Team', Value: 'actions' }, + ], + }); + }); + + it('uses an empty tag list when no tags are configured', async () => { + const store = createAwsSsmRunnerConfigStore(); + + await store.create({ runnerId: 'runner-1', value: 'registration-config' }); + + expect(putParameterMock).toHaveBeenCalledWith('/runner/tokens/runner-1', 'registration-config', true, { + tags: [], + }); + }); + + it.each([undefined, '', ' '])('rejects missing or blank SSM_TOKEN_PATH %j before writing', (tokenPath) => { + setTokenPath(tokenPath); + + expect(() => createAwsSsmRunnerConfigStore()).toThrow('Environment variable SSM_TOKEN_PATH is not set'); + expect(putParameterMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['{}', 'Tags must be an array'], + ['[null]', 'Tag at index 0 must be an object'], + [JSON.stringify([{ Key: '', Value: 'test' }]), "Tag at index 0 has missing or invalid 'Key' property"], + [JSON.stringify([{ Key: 'Environment' }]), "Tag at index 0 has missing or invalid 'Value' property"], + ])('rejects invalid legacy SSM parameter tags', (tags, reason) => { + process.env.SSM_PARAMETER_STORE_TAGS = tags; + + expect(() => createAwsSsmRunnerConfigStore()).toThrow(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${reason}`); + expect(putParameterMock).not.toHaveBeenCalled(); + }); + + it('treats a blank legacy tag value as no configured tags', async () => { + process.env.SSM_PARAMETER_STORE_TAGS = ' '; + const store = createAwsSsmRunnerConfigStore(); + + await store.create({ runnerId: 'runner-1', value: 'jit-config' }); + + expect(putParameterMock).toHaveBeenCalledWith('/runner/tokens/runner-1', 'jit-config', true, { tags: [] }); + }); +}); + +function setTokenPath(tokenPath: string | undefined): void { + if (tokenPath === undefined) { + delete process.env.SSM_TOKEN_PATH; + } else { + process.env.SSM_TOKEN_PATH = tokenPath; + } +} diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts new file mode 100644 index 0000000000..179bcfa87c --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts @@ -0,0 +1,37 @@ +import { putParameter } from '@aws-github-runner/aws-ssm-util'; + +import type { RunnerConfigMetadataTag, RunnerConfigRecord, RunnerConfigStore } from '../../core'; +import type {} from './environment'; +import { loadSsmParameterStoreTagsFromEnvironment } from './parameter-store-tags'; + +interface AwsSsmRunnerConfigStoreConfig { + tokenPath: string; + parameterStoreTags: { Key: string; Value: string }[]; +} + +export function createAwsSsmRunnerConfigStore(): RunnerConfigStore { + const tokenPath = process.env.SSM_TOKEN_PATH; + if (!tokenPath || tokenPath.trim() === '') { + throw new Error('Environment variable SSM_TOKEN_PATH is not set'); + } + + return new AwsSsmRunnerConfigStore({ + tokenPath, + parameterStoreTags: loadSsmParameterStoreTagsFromEnvironment(), + }); +} + +class AwsSsmRunnerConfigStore implements RunnerConfigStore { + readonly maxWritesPerSecond = 40; + + constructor(private readonly config: AwsSsmRunnerConfigStoreConfig) {} + + async create(record: RunnerConfigRecord, options: { metadataTags?: RunnerConfigMetadataTag[] } = {}): Promise { + await putParameter(`${this.config.tokenPath}/${record.runnerId}`, record.value, true, { + tags: [ + ...(options.metadataTags ?? []).map(({ key, value }) => ({ Key: key, Value: value })), + ...this.config.parameterStoreTags, + ], + }); + } +} diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts new file mode 100644 index 0000000000..7f9df413c0 --- /dev/null +++ b/lambdas/libs/storage-providers/core/index.ts @@ -0,0 +1,14 @@ +export interface RunnerConfigMetadataTag { + key: string; + value: string; +} + +export interface RunnerConfigRecord { + runnerId: string; + value: string; +} + +export interface RunnerConfigStore { + readonly maxWritesPerSecond?: number; + create(record: RunnerConfigRecord, options?: { metadataTags?: RunnerConfigMetadataTag[] }): Promise; +} diff --git a/lambdas/libs/storage-providers/environment.d.ts b/lambdas/libs/storage-providers/environment.d.ts new file mode 100644 index 0000000000..0f7ade9095 --- /dev/null +++ b/lambdas/libs/storage-providers/environment.d.ts @@ -0,0 +1,9 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + RUNNER_CONFIG_STORAGE_PROVIDER?: string; + } + } +} diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts new file mode 100644 index 0000000000..862924118b --- /dev/null +++ b/lambdas/libs/storage-providers/index.ts @@ -0,0 +1,2 @@ +export type { RunnerConfigMetadataTag, RunnerConfigRecord, RunnerConfigStore } from './core'; +export { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; diff --git a/lambdas/libs/storage-providers/package.json b/lambdas/libs/storage-providers/package.json new file mode 100644 index 0000000000..2b753fc436 --- /dev/null +++ b/lambdas/libs/storage-providers/package.json @@ -0,0 +1,29 @@ +{ + "name": "@aws-github-runner/storage-providers", + "version": "1.0.0", + "main": "index.ts", + "exports": { + ".": "./index.ts" + }, + "type": "module", + "license": "MIT", + "scripts": { + "test": "NODE_ENV=test nx test", + "test:watch": "NODE_ENV=test nx test --watch", + "lint": "eslint .", + "format": "prettier --write \"**/*.ts\"", + "format-check": "prettier --check \"**/*.ts\"", + "all": "yarn format && yarn lint && yarn test" + }, + "dependencies": { + "@aws-github-runner/aws-ssm-util": "*" + }, + "nx": { + "includedScripts": [ + "format", + "format-check", + "lint", + "all" + ] + } +} diff --git a/lambdas/libs/storage-providers/runner-config.test.ts b/lambdas/libs/storage-providers/runner-config.test.ts new file mode 100644 index 0000000000..10467ebdb4 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-config.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; +import type { RunnerConfigStore } from './core'; +import { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; + +vi.mock('./aws/ssm/runner-config-store', () => ({ + createAwsSsmRunnerConfigStore: vi.fn(), +})); + +const createAwsSsmRunnerConfigStoreMock = vi.mocked(createAwsSsmRunnerConfigStore); +const cleanEnv = process.env; + +describe('runner config store selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + resetRunnerConfigStore(); + }); + + it.each([undefined, '', ' '])('uses aws_ssm for default selector input %j', (provider) => { + setProvider(provider); + const store = stubStore(); + + expect(getRunnerConfigStore()).toBe(store); + expect(createAwsSsmRunnerConfigStoreMock).toHaveBeenCalledOnce(); + }); + + it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubStore(); + + expect(getRunnerConfigStore()).toBe(store); + expect(createAwsSsmRunnerConfigStoreMock).toHaveBeenCalledOnce(); + }); + + it('rejects an unsupported provider on first use', () => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + + expect(createAwsSsmRunnerConfigStoreMock).not.toHaveBeenCalled(); + expect(() => getRunnerConfigStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsSsmRunnerConfigStoreMock).not.toHaveBeenCalled(); + }); + + it('selects lazily and caches the created store', () => { + const store = stubStore(); + + expect(createAwsSsmRunnerConfigStoreMock).not.toHaveBeenCalled(); + const first = getRunnerConfigStore(); + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + const second = getRunnerConfigStore(); + + expect(first).toBe(store); + expect(second).toBe(store); + expect(createAwsSsmRunnerConfigStoreMock).toHaveBeenCalledOnce(); + }); + + it('selects again after the test reset', () => { + const firstStore = stubStore(); + expect(getRunnerConfigStore()).toBe(firstStore); + + const secondStore = { create: vi.fn() } satisfies RunnerConfigStore; + createAwsSsmRunnerConfigStoreMock.mockReturnValue(secondStore); + resetRunnerConfigStore(); + + expect(getRunnerConfigStore()).toBe(secondStore); + expect(createAwsSsmRunnerConfigStoreMock).toHaveBeenCalledTimes(2); + }); +}); + +function setProvider(provider: string | undefined): void { + if (provider === undefined) { + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + } else { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + } +} + +function stubStore(): RunnerConfigStore { + const store = { create: vi.fn() } satisfies RunnerConfigStore; + createAwsSsmRunnerConfigStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/runner-config.ts b/lambdas/libs/storage-providers/runner-config.ts new file mode 100644 index 0000000000..dfac2fcfb1 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-config.ts @@ -0,0 +1,46 @@ +import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; +import type { RunnerConfigStore } from './core'; +import type {} from './environment'; + +type RunnerConfigStoreFactory = () => RunnerConfigStore; + +const providerFactories = { + aws_ssm: createAwsSsmRunnerConfigStore, +} as const satisfies Record; + +type RunnerConfigStorageProvider = keyof typeof providerFactories; + +const defaultProvider = 'aws_ssm' satisfies RunnerConfigStorageProvider; + +let runnerConfigStore: RunnerConfigStore | undefined; + +export function getRunnerConfigStore(): RunnerConfigStore { + runnerConfigStore ??= providerFactories[resolveProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + return runnerConfigStore; +} + +// Test-only reset for cases that need to exercise first-use environment selection. +export function resetRunnerConfigStore(): void { + runnerConfigStore = undefined; +} + +function resolveProvider(provider: unknown): RunnerConfigStorageProvider { + if (provider === undefined) { + return defaultProvider; + } + + if (typeof provider !== 'string') { + throw new Error(`Unsupported runner config storage provider '${String(provider)}'`); + } + + const normalizedProvider = provider.trim().toLowerCase(); + if (normalizedProvider === '') { + return defaultProvider; + } + + if (!Object.prototype.hasOwnProperty.call(providerFactories, normalizedProvider)) { + throw new Error(`Unsupported runner config storage provider '${String(provider)}'`); + } + + return normalizedProvider as RunnerConfigStorageProvider; +} diff --git a/lambdas/libs/storage-providers/tsconfig.json b/lambdas/libs/storage-providers/tsconfig.json new file mode 100644 index 0000000000..139069a7cf --- /dev/null +++ b/lambdas/libs/storage-providers/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["**/*.ts"], + "exclude": ["**/*.test.ts", "vitest.config.ts"] +} diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts new file mode 100644 index 0000000000..a5812ad13e --- /dev/null +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -0,0 +1,14 @@ +import { resolve } from 'path'; + +import { mergeConfig } from 'vitest/config'; +import defaultConfig from '../../vitest.base.config'; + +export default mergeConfig(defaultConfig, { + test: { + setupFiles: [resolve(__dirname, '../../aws-vitest-setup.ts')], + coverage: { + include: ['index.ts', 'runner-config.ts', 'core/**/*.ts', 'aws/**/*.ts'], + exclude: ['**/*.test.ts', '**/*.d.ts'], + }, + }, +}); diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 10175a11aa..1425694615 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -164,6 +164,7 @@ __metadata: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" "@aws-github-runner/compute-providers": "npm:*" + "@aws-github-runner/storage-providers": "npm:*" "@aws-lambda-powertools/parameters": "npm:^2.31.0" "@aws-sdk/client-ec2": "npm:^3.1009.0" "@aws-sdk/client-sqs": "npm:^3.1009.0" @@ -209,6 +210,14 @@ __metadata: languageName: unknown linkType: soft +"@aws-github-runner/storage-providers@npm:*, @aws-github-runner/storage-providers@workspace:libs/storage-providers": + version: 0.0.0-use.local + resolution: "@aws-github-runner/storage-providers@workspace:libs/storage-providers" + dependencies: + "@aws-github-runner/aws-ssm-util": "npm:*" + languageName: unknown + linkType: soft + "@aws-github-runner/termination-watcher@workspace:functions/termination-watcher": version: 0.0.0-use.local resolution: "@aws-github-runner/termination-watcher@workspace:functions/termination-watcher" From 90c0471bec593e8c1420d816a20b791aeff12768 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 18 Aug 2026 23:32:22 +0200 Subject: [PATCH 38/49] refactor(storage): extract group cache and cleanup --- .../control-plane/src/lambda.test.ts | 36 ++++-- lambdas/functions/control-plane/src/lambda.ts | 6 +- .../src/local-ssm-housekeeper.ts | 9 +- .../functions/control-plane/src/modules.d.ts | 1 - .../src/pool/pool-contract.test.ts | 6 - .../control-plane/src/pool/pool.test.ts | 15 --- .../functions/control-plane/src/pool/pool.ts | 11 +- .../src/scale-runners/github-runner.ts | 76 +++-------- .../scale-runners/scale-up-contract.test.ts | 3 - .../src/scale-runners/scale-up.test.ts | 21 +--- .../src/scale-runners/scale-up.ts | 10 -- .../src/scale-runners/ssm-housekeeper.test.ts | 118 ------------------ .../ec2/src/control-plane/runner-config.ts | 2 +- .../ec2/src/control-plane/scale-up.test.ts | 4 +- lambdas/libs/compute-providers/core/index.ts | 4 +- .../aws/ssm/environment.d.ts | 2 + .../aws/ssm/runner-config-housekeeper.test.ts | 97 ++++++++++++++ .../aws/ssm/runner-config-housekeeper.ts} | 12 +- .../aws/ssm/runner-config-store.test.ts | 11 +- .../aws/ssm/runner-config-store.ts | 35 ++++-- .../aws/ssm/runner-group-cache-store.test.ts | 72 +++++++++++ .../aws/ssm/runner-group-cache-store.ts | 41 ++++++ lambdas/libs/storage-providers/core/index.ts | 15 ++- lambdas/libs/storage-providers/index.ts | 9 +- lambdas/libs/storage-providers/package.json | 8 +- lambdas/libs/storage-providers/provider.ts | 26 ++++ .../storage-providers/runner-config.test.ts | 4 +- .../libs/storage-providers/runner-config.ts | 31 +---- .../runner-group-cache.test.ts | 83 ++++++++++++ .../storage-providers/runner-group-cache.ts | 23 ++++ .../libs/storage-providers/vitest.config.ts | 2 +- lambdas/yarn.lock | 4 + 32 files changed, 488 insertions(+), 309 deletions(-) delete mode 100644 lambdas/functions/control-plane/src/scale-runners/ssm-housekeeper.test.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.test.ts rename lambdas/{functions/control-plane/src/scale-runners/ssm-housekeeper.ts => libs/storage-providers/aws/ssm/runner-config-housekeeper.ts} (84%) create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts create mode 100644 lambdas/libs/storage-providers/provider.ts create mode 100644 lambdas/libs/storage-providers/runner-group-cache.test.ts create mode 100644 lambdas/libs/storage-providers/runner-group-cache.ts diff --git a/lambdas/functions/control-plane/src/lambda.test.ts b/lambdas/functions/control-plane/src/lambda.test.ts index 26b130ffe1..f93b4eac49 100644 --- a/lambdas/functions/control-plane/src/lambda.test.ts +++ b/lambdas/functions/control-plane/src/lambda.test.ts @@ -1,4 +1,5 @@ import { captureLambdaHandler, logger } from '@aws-github-runner/aws-powertools-util'; +import { getRunnerConfigStore, type RunnerConfigStore } from '@aws-github-runner/storage-providers'; import { Context, SQSEvent, SQSRecord } from 'aws-lambda'; import { addMiddleware, adjustPool, scaleDownHandler, scaleUpHandler, ssmHousekeeper, jobRetryCheck } from './lambda'; @@ -6,7 +7,6 @@ import { adjust } from './pool/pool'; import { scaleDown } from './scale-runners/scale-down'; import { scaleUp } from './scale-runners/scale-up'; import type { ActionRequestMessage } from './scale-runners/types'; -import { cleanSSMTokens } from './scale-runners/ssm-housekeeper'; import { checkAndRetryJob } from './scale-runners/job-retry'; import { describe, it, expect, vi, MockedFunction, beforeEach } from 'vitest'; @@ -64,10 +64,17 @@ const context: Context = { vi.mock('./pool/pool'); vi.mock('./scale-runners/scale-down'); vi.mock('./scale-runners/scale-up'); -vi.mock('./scale-runners/ssm-housekeeper'); vi.mock('./scale-runners/job-retry'); vi.mock('@aws-github-runner/aws-powertools-util'); vi.mock('@aws-github-runner/aws-ssm-util'); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getRunnerConfigStore: vi.fn(), +})); + +const runnerConfigStore = { + create: vi.fn(), + houseKeeper: vi.fn(), +} satisfies RunnerConfigStore; describe('Test scale up lambda wrapper.', () => { it('Do not handle empty record sets.', async () => { @@ -297,22 +304,31 @@ describe('Test middleware', () => { }); describe('Test ssm housekeeper lambda wrapper.', () => { - it('Invoke without errors.', async () => { - vi.mocked(cleanSSMTokens).mockResolvedValue(); + beforeEach(() => { + vi.mocked(getRunnerConfigStore).mockReturnValue(runnerConfigStore); + }); - process.env.SSM_CLEANUP_CONFIG = JSON.stringify({ - dryRun: false, - minimumDaysOld: 1, - tokenPath: '/path/to/tokens/', - }); + it('Invoke without errors.', async () => { + runnerConfigStore.houseKeeper.mockResolvedValue(); await expect(ssmHousekeeper({}, context)).resolves.not.toThrow(); + expect(getRunnerConfigStore).toHaveBeenCalledOnce(); + expect(runnerConfigStore.houseKeeper).toHaveBeenCalledOnce(); }); it('Errors not throws.', async () => { - vi.mocked(cleanSSMTokens).mockRejectedValue(new Error()); + runnerConfigStore.houseKeeper.mockRejectedValue(new Error()); await expect(ssmHousekeeper({}, context)).resolves.not.toThrow(); }); + + it('does not catch provider construction errors', async () => { + const error = new Error('Invalid provider configuration'); + vi.mocked(getRunnerConfigStore).mockImplementation(() => { + throw error; + }); + + await expect(ssmHousekeeper({}, context)).rejects.toBe(error); + }); }); describe('Test job retry check wrapper', () => { diff --git a/lambdas/functions/control-plane/src/lambda.ts b/lambdas/functions/control-plane/src/lambda.ts index d229a0350e..270980c3bc 100644 --- a/lambdas/functions/control-plane/src/lambda.ts +++ b/lambdas/functions/control-plane/src/lambda.ts @@ -1,13 +1,13 @@ import middy from '@middy/core'; import { logger, setContext } from '@aws-github-runner/aws-powertools-util'; import { captureLambdaHandler, tracer } from '@aws-github-runner/aws-powertools-util'; +import { getRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { Context, type SQSBatchItemFailure, type SQSBatchResponse, SQSEvent } from 'aws-lambda'; import { PoolEvent, adjust } from './pool/pool'; import { scaleDown } from './scale-runners/scale-down'; import { scaleUp } from './scale-runners/scale-up'; import type { ActionRequestMessage, ActionRequestMessageSQS } from './scale-runners/types'; -import { SSMCleanupOptions, cleanSSMTokens } from './scale-runners/ssm-housekeeper'; import { checkAndRetryJob } from './scale-runners/job-retry'; export async function scaleUpHandler(event: SQSEvent, context: Context): Promise { @@ -121,10 +121,10 @@ addMiddleware(); export async function ssmHousekeeper(event: unknown, context: Context): Promise { setContext(context, 'lambda.ts'); logger.logEventIfEnabled(event); - const config = JSON.parse(process.env.SSM_CLEANUP_CONFIG) as SSMCleanupOptions; + const runnerConfigStore = getRunnerConfigStore(); try { - await cleanSSMTokens(config); + await runnerConfigStore.houseKeeper(); } catch (e) { logger.error(`${(e as Error).message}`, { error: e as Error }); } diff --git a/lambdas/functions/control-plane/src/local-ssm-housekeeper.ts b/lambdas/functions/control-plane/src/local-ssm-housekeeper.ts index ec635b13ad..08b062a193 100644 --- a/lambdas/functions/control-plane/src/local-ssm-housekeeper.ts +++ b/lambdas/functions/control-plane/src/local-ssm-housekeeper.ts @@ -1,11 +1,14 @@ -import { cleanSSMTokens } from './scale-runners/ssm-housekeeper'; +import { getRunnerConfigStore } from '@aws-github-runner/storage-providers'; export function run(): void { - cleanSSMTokens({ + process.env.SSM_CLEANUP_CONFIG = JSON.stringify({ dryRun: true, minimumDaysOld: 3, tokenPath: '/ghr/my-env/runners/tokens', - }) + }); + + getRunnerConfigStore() + .houseKeeper() .then() .catch((e) => { console.log(e); diff --git a/lambdas/functions/control-plane/src/modules.d.ts b/lambdas/functions/control-plane/src/modules.d.ts index 84b0d23a02..d32f8431e0 100644 --- a/lambdas/functions/control-plane/src/modules.d.ts +++ b/lambdas/functions/control-plane/src/modules.d.ts @@ -18,7 +18,6 @@ declare namespace NodeJS { RUNNER_OWNER: string; COMPUTE_PROVIDER_TYPE?: string; SCALE_DOWN_CONFIG: string; - SSM_CLEANUP_CONFIG: string; SUBNET_IDS: string; INSTANCE_TYPES: string; INSTANCE_TARGET_CAPACITY_TYPE: 'on-demand' | 'spot'; 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 28e38c6a77..a949afae39 100644 --- a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts @@ -1,6 +1,5 @@ import type { Octokit } from '@octokit/rest'; import type { ComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; -import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { beforeEach, vi } from 'vitest'; import { definePoolContractTests } from '../test/compute-provider-contracts/pool'; @@ -21,7 +20,6 @@ vi.mock('../github/auth', () => ({ vi.mock('../scale-runners/github-runner', () => ({ createStartRunnerConfig: vi.fn(), getGitHubEnterpriseApiUrl: vi.fn(), - validateSsmParameterStoreTags: vi.fn(), })); const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); @@ -49,9 +47,6 @@ const computeProviders = providerTypes.map((type) => ({ beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; - resetRunnerConfigStore(); - mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); mockedInstallationAuth.mockResolvedValue({ type: 'token', @@ -65,7 +60,6 @@ beforeEach(() => { }); mockedCreateClient.mockResolvedValue(githubClient); vi.mocked(githubRunner.getGitHubEnterpriseApiUrl).mockReturnValue({ ghesApiUrl: '', ghesBaseUrl: '' }); - vi.mocked(githubRunner.validateSsmParameterStoreTags).mockReturnValue([]); vi.mocked(githubClient.apps.getOrgInstallation).mockResolvedValue({ data: { id: 2 } } as never); vi.mocked(githubClient.paginate).mockResolvedValue([]); }); diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index d99a7c15f7..a4bdca3000 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -4,7 +4,6 @@ import * as nock from 'nock'; import { createRunners } from '@aws-github-runner/compute-providers/aws/ec2/control-plane/runner-config'; import { listEC2Runners } from '@aws-github-runner/compute-providers/aws/ec2/control-plane/runners'; -import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; import * as ghAuth from '../github/auth'; import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner'; import { adjust } from './pool'; @@ -52,7 +51,6 @@ vi.mock('../scale-runners/github-runner', async () => ({ ghesApiUrl: '', ghesBaseUrl: '', }), - validateSsmParameterStoreTags: vi.fn().mockReturnValue([]), })); const mocktokit = Octokit as MockedClass; @@ -135,7 +133,6 @@ beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); process.env = { ...cleanEnv }; - resetRunnerConfigStore(); process.env.GITHUB_APP_KEY_BASE64 = 'TEST_CERTIFICATE_DATA'; process.env.GITHUB_APP_ID = '1337'; process.env.GITHUB_APP_CLIENT_ID = 'TEST_CLIENT_ID'; @@ -145,7 +142,6 @@ beforeEach(() => { process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; process.env.LAUNCH_TEMPLATE_NAME = 'lt-1'; process.env.SUBNET_IDS = 'subnet-123'; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; process.env.INSTANCE_TYPES = 'm5.large'; process.env.INSTANCE_TARGET_CAPACITY_TYPE = 'spot'; process.env.RUNNER_OWNER = ORG; @@ -255,17 +251,6 @@ describe('Test simple pool.', () => { expect(mockListRunners).not.toHaveBeenCalled(); }); - it('Rejects an unsupported runner config store before GitHub or runner lookups.', async () => { - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'unsupported-provider'; - - await expect(adjust({ poolSize: 10, type: 'ec2' })).rejects.toThrow( - "Unsupported runner config storage provider 'unsupported-provider'", - ); - - expect(mockedAppAuth).not.toHaveBeenCalled(); - expect(mockListRunners).not.toHaveBeenCalled(); - }); - it('Should not top up if pool size is reached.', async () => { await adjust({ poolSize: 1, type: 'ec2' }); expect(createRunners).not.toHaveBeenCalled(); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index ab7f5a7c94..21e91adebc 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -1,7 +1,6 @@ import { Octokit } from '@octokit/rest'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; -import { getRunnerConfigStore } from '@aws-github-runner/storage-providers'; import yn from 'yn'; import { @@ -11,7 +10,7 @@ import { getStoredInstallationId, } from '../github/auth'; import { controlPlaneProviderRegistry } from '../control-plane-providers'; -import { getGitHubEnterpriseApiUrl, validateSsmParameterStoreTags } from '../scale-runners/github-runner'; +import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner'; import type { RunnerStatus } from './pool-provider'; const logger = createChildLogger('pool'); @@ -32,16 +31,10 @@ export async function adjust(event: PoolEvent): Promise { const runnerGroup = process.env.RUNNER_GROUP_NAME || ''; const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || ''; const environment = process.env.ENVIRONMENT; - const ssmConfigPath = process.env.SSM_CONFIG_PATH || ''; const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false }); const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral }); const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false }); const runnerOwner = process.env.RUNNER_OWNER; - const ssmParameterStoreTags: { Key: string; Value: string }[] = - process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== '' - ? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS) - : []; - getRunnerConfigStore(); // -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited // when unset so the pool keeps its previous behavior on stacks that do not provide the variable. const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1'); @@ -104,8 +97,6 @@ export async function adjust(event: PoolEvent): Promise { runnerNamePrefix, runnerType: 'Org', disableAutoUpdate: disableAutoUpdate, - ssmConfigPath, - ssmParameterStoreTags, }, numberOfRunners: topUp, githubInstallationClient, diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 79c78608dc..ec78a2a2b5 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -1,8 +1,8 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { getParameter, putParameter } from '@aws-github-runner/aws-ssm-util'; import { + getRunnerGroupCacheStore, getRunnerConfigStore, - type RunnerConfigMetadataTag, + type RunnerConfigMetadata, type RunnerConfigStore, } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; @@ -19,7 +19,7 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { - getRunnerConfigMetadataTags?: (runnerId: string) => RunnerConfigMetadataTag[]; + getRunnerConfigMetadata?: (runnerId: string) => RunnerConfigMetadata[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; } @@ -56,37 +56,6 @@ function quoteShellArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -export function validateSsmParameterStoreTags(tagsJson: string): { Key: string; Value: string }[] { - try { - const tags = JSON.parse(tagsJson); - - if (!Array.isArray(tags)) { - throw new Error('Tags must be an array'); - } - - if (tags.length === 0) { - return []; - } - - tags.forEach((tag, index) => { - if (typeof tag !== 'object' || tag === null) { - throw new Error(`Tag at index ${index} must be an object`); - } - if (!tag.Key || typeof tag.Key !== 'string' || tag.Key.trim() === '') { - throw new Error(`Tag at index ${index} has missing or invalid 'Key' property`); - } - if (!Object.prototype.hasOwnProperty.call(tag, 'Value') || typeof tag.Value !== 'string') { - throw new Error(`Tag at index ${index} has missing or invalid 'Value' property`); - } - }); - - return tags; - } catch (err) { - logger.error('Invalid SSM_PARAMETER_STORE_TAGS format', { error: err }); - throw new Error(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${(err as Error).message}`); - } -} - async function getGithubRunnerRegistrationToken(githubRunnerConfig: CreateGitHubRunnerConfig, ghClient: Octokit) { const registrationToken = githubRunnerConfig.runnerType === 'Org' @@ -191,39 +160,30 @@ export async function getRunnerGroupId( // if the runnerType is Repo, then runnerGroupId is default to 1 let runnerGroupId: number | undefined = 1; if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) { - let runnerGroup: string | undefined; - // check if runner group id is already stored in SSM Parameter Store and - // use it if it exists to avoid API call to GitHub + const runnerGroupCacheStore = getRunnerGroupCacheStore(); + let cachedRunnerGroupId: number | undefined; + // Use a cached runner group id when available to avoid an API call to GitHub. try { - runnerGroup = await getParameter( - `${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}`, - ); + cachedRunnerGroupId = await runnerGroupCacheStore.get(githubRunnerConfig.runnerGroup); } catch (err) { logger.debug('Handling error:', err as Error); - logger.warn( - `SSM Parameter "${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}" - for Runner group ${githubRunnerConfig.runnerGroup} does not exist`, - ); + logger.warn(`Cached id for runner group ${githubRunnerConfig.runnerGroup} does not exist`); } - if (runnerGroup === undefined) { + if (cachedRunnerGroupId === undefined) { // get runner group id from GitHub runnerGroupId = await getRunnerGroupByName(ghClient, githubRunnerConfig); - // store runner group id in SSM + // cache the runner group id try { - await putParameter( - `${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}`, - runnerGroupId.toString(), - false, - { - tags: githubRunnerConfig.ssmParameterStoreTags, - }, - ); + await runnerGroupCacheStore.create({ + runnerGroupName: githubRunnerConfig.runnerGroup, + runnerGroupId, + }); } catch (err) { - logger.debug('Error storing runner group id in SSM Parameter Store', err as Error); + logger.debug('Error storing runner group id in cache', err as Error); throw err; } } else { - runnerGroupId = parseInt(runnerGroup); + runnerGroupId = cachedRunnerGroupId; } } return runnerGroupId; @@ -294,7 +254,7 @@ async function createRegistrationTokenConfig( for (const runnerId of runnerIds) { await runnerConfigStore.create( { runnerId, value: runnerServiceConfig.join(' ') }, - { metadataTags: options.getRunnerConfigMetadataTags?.(runnerId) }, + { metadata: options.getRunnerConfigMetadata?.(runnerId) }, ); if (isDelay) { // Delay to stay within the selected store's maximum write throughput. @@ -362,7 +322,7 @@ async function createJitConfig( }); await runnerConfigStore.create( { runnerId, value: runnerConfig.data.encoded_jit_config }, - { metadataTags: options.getRunnerConfigMetadataTags?.(runnerId) }, + { metadata: options.getRunnerConfigMetadata?.(runnerId) }, ); if (isDelay) { // Delay to stay within the selected store's maximum write throughput. 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 5d190f3e5f..3c1a0362bb 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 @@ -1,5 +1,4 @@ import type { Octokit } from '@octokit/rest'; -import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { beforeEach, vi } from 'vitest'; import { providerTypes } from '../test/compute-provider-contracts/provider-types'; @@ -57,8 +56,6 @@ const computeProviders = providerTypes.map((type) => ({ beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; - resetRunnerConfigStore(); mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); mockedInstallationAuth.mockResolvedValue({ diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 275e163bf8..0f86fefc10 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -17,7 +17,7 @@ import type { ScaleUpComputeProvider, } from './types'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { resetRunnerConfigStore } from '@aws-github-runner/storage-providers'; +import { resetRunnerConfigStore, resetRunnerGroupCacheStore } from '@aws-github-runner/storage-providers'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Octokit } from '@octokit/rest'; @@ -148,6 +148,7 @@ function setDefaults() { process.env.GITHUB_APP_CLIENT_SECRET = 'TEST_CLIENT_SECRET'; process.env.RUNNERS_MAXIMUM_COUNT = '3'; process.env.ENVIRONMENT = EXPECTED_RUNNER_PARAMS.environment; + process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; } @@ -170,7 +171,7 @@ async function createTestProviderRunners(input: CreateScaleUpRunnersInput [{ key: 'RunnerId', value: runnerId }], + getRunnerConfigMetadata: (runnerId) => [{ key: 'RunnerId', value: runnerId }], }, ); } catch { @@ -190,6 +191,7 @@ beforeEach(() => { vi.clearAllMocks(); setDefaults(); resetRunnerConfigStore(); + resetRunnerGroupCacheStore(); defaultSSMGetParameterMockImpl(); defaultOctokitMockImpl(); @@ -1198,6 +1200,7 @@ describe('scaleUp with public GH', () => { it('creates a ephemeral runner with JIT config.', async () => { process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.ENABLE_JOB_QUEUED_CHECK = 'false'; + delete process.env.SSM_CONFIG_PATH; process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; await scaleUpModule.scaleUp(TEST_DATA); expect(mockOctokit.actions.getJobForWorkflowRun).not.toBeCalled(); @@ -1243,6 +1246,7 @@ describe('scaleUp with public GH', () => { process.env.ENABLE_JIT_CONFIG = 'true'; process.env.ENABLE_JOB_QUEUED_CHECK = 'false'; process.env.RUNNER_LABELS = 'jit'; + delete process.env.SSM_CONFIG_PATH; process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; await scaleUpModule.scaleUp(TEST_DATA); expect(mockOctokit.actions.getJobForWorkflowRun).not.toBeCalled(); @@ -2169,19 +2173,6 @@ describe('compute provider selection', () => { }); }); -describe('runner config store preflight', () => { - it('rejects an unsupported store before resolving compute or GitHub providers', async () => { - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'unsupported-provider'; - - await expect(scaleUpModule.scaleUp(TEST_DATA)).rejects.toThrow( - "Unsupported runner config storage provider 'unsupported-provider'", - ); - - expect(mockedResolveCapability).not.toHaveBeenCalled(); - expect(mockedAppAuth).not.toHaveBeenCalled(); - }); -}); - describe('Multi-app round-robin', () => { const mockedGetAppCount = vi.mocked(ghAuth.getAppCount); const mockedGetStoredInstallationId = vi.mocked(ghAuth.getStoredInstallationId); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index a33a8c9705..48733c13c9 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -1,6 +1,5 @@ import { addPersistentContextToChildLogger, createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; -import { getRunnerConfigStore } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; import yn from 'yn'; @@ -12,7 +11,6 @@ import { resolveInstallationId, isJobQueued, UnsupportedEventError, - validateSsmParameterStoreTags, } from './github-runner'; import { publishRetryMessage } from './job-retry'; import type { @@ -86,12 +84,6 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise { - beforeEach(() => { - mockSSMClient.reset(); - mockSSMClient.on(GetParametersByPathCommand).resolves({ - Parameters: undefined, - }); - mockSSMClient.on(GetParametersByPathCommand, { Path: tokenPath }).resolves({ - Parameters: [ - { - Name: tokenPath + 'i-old-01', - LastModifiedDate: dateOld, - }, - ], - NextToken: 'next', - }); - mockSSMClient.on(GetParametersByPathCommand, { Path: tokenPath, NextToken: 'next' }).resolves({ - Parameters: [ - { - Name: tokenPath + 'i-new-01', - LastModifiedDate: now, - }, - ], - NextToken: undefined, - }); - }); - - it('should delete parameters older then minimumDaysOld', async () => { - await cleanSSMTokens({ - dryRun: false, - minimumDaysOld: deleteAmisOlderThenDays, - tokenPath: tokenPath, - }); - - expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersByPathCommand, { Path: tokenPath }); - expect(mockSSMClient).toHaveReceivedCommandWith(DeleteParameterCommand, { Name: tokenPath + 'i-old-01' }); - expect(mockSSMClient).not.toHaveReceivedCommandWith(DeleteParameterCommand, { Name: tokenPath + 'i-new-01' }); - }); - - it('should not delete when dry run is activated', async () => { - await cleanSSMTokens({ - dryRun: true, - minimumDaysOld: deleteAmisOlderThenDays, - tokenPath: tokenPath, - }); - - expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersByPathCommand, { Path: tokenPath }); - expect(mockSSMClient).not.toHaveReceivedCommandWith(DeleteParameterCommand, { Name: tokenPath + 'i-old-01' }); - expect(mockSSMClient).not.toHaveReceivedCommandWith(DeleteParameterCommand, { Name: tokenPath + 'i-new-01' }); - }); - - it('should not call delete when no parameters are found.', async () => { - await expect( - cleanSSMTokens({ - dryRun: false, - minimumDaysOld: deleteAmisOlderThenDays, - tokenPath: 'no-exist', - }), - ).resolves.not.toThrow(); - - expect(mockSSMClient).not.toHaveReceivedCommandWith(DeleteParameterCommand, { Name: tokenPath + 'i-old-01' }); - expect(mockSSMClient).not.toHaveReceivedCommandWith(DeleteParameterCommand, { Name: tokenPath + 'i-new-01' }); - }); - - it('should not error on delete failure.', async () => { - mockSSMClient.on(DeleteParameterCommand).rejects(new Error('ParameterNotFound')); - - await expect( - cleanSSMTokens({ - dryRun: false, - minimumDaysOld: deleteAmisOlderThenDays, - tokenPath: tokenPath, - }), - ).resolves.not.toThrow(); - }); - - it('should only accept valid options.', async () => { - await expect( - cleanSSMTokens({ - dryRun: false, - minimumDaysOld: undefined as unknown as number, - tokenPath: tokenPath, - }), - ).rejects.toBeInstanceOf(Error); - - await expect( - cleanSSMTokens({ - dryRun: false, - minimumDaysOld: 0, - tokenPath: tokenPath, - }), - ).rejects.toBeInstanceOf(Error); - - await expect( - cleanSSMTokens({ - dryRun: false, - minimumDaysOld: 1, - tokenPath: undefined as unknown as string, - }), - ).rejects.toBeInstanceOf(Error); - }); -}); diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts index 6e6946c7e7..6d195d958b 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts @@ -141,7 +141,7 @@ async function terminateFailedInstances(instanceIds: string[]): Promise { function createEc2StartRunnerConfigOptions(): StartRunnerConfigOptions { return { - getRunnerConfigMetadataTags: (instanceId) => [{ key: 'InstanceId', value: instanceId }], + getRunnerConfigMetadata: (instanceId) => [{ key: 'InstanceId', value: instanceId }], onJitConfigCreated: async (instanceId, metadata) => await tagEc2RunnerMetadata(instanceId, metadata), }; } diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts index 7695839ba4..9018e08ff9 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts @@ -51,8 +51,6 @@ function runnerConfig(overrides: Partial = {}): Create runnerOwner, runnerType: 'Org', disableAutoUpdate: false, - ssmConfigPath: '/github-action-runners/default/runners/config', - ssmParameterStoreTags: [], ...overrides, }; } @@ -181,7 +179,7 @@ describe('scaleUp with GHES', () => { { Key: 'ghr:runner_labels', Value: 'label1,label2' }, ]); const [, , , options] = mockCreateStartRunnerConfig.mock.calls[0]; - expect(options?.getRunnerConfigMetadataTags?.('i-12345')).toEqual([{ key: 'InstanceId', value: 'i-12345' }]); + expect(options?.getRunnerConfigMetadata?.('i-12345')).toEqual([{ key: 'InstanceId', value: 'i-12345' }]); }); it('chunks comma-joined GitHub runner labels by the EC2 tag value max length', async () => { diff --git a/lambdas/libs/compute-providers/core/index.ts b/lambdas/libs/compute-providers/core/index.ts index 9125dd9b90..908b67fb54 100644 --- a/lambdas/libs/compute-providers/core/index.ts +++ b/lambdas/libs/compute-providers/core/index.ts @@ -21,8 +21,6 @@ export interface CreateGitHubRunnerConfig { runnerOwner: string; runnerType: RunnerType; disableAutoUpdate: boolean; - ssmConfigPath: string; - ssmParameterStoreTags: { Key: string; Value: string }[]; } export interface GitHubRunnerMetadata { @@ -31,7 +29,7 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { - getRunnerConfigMetadataTags?: (runnerId: string) => { key: string; value: string }[]; + getRunnerConfigMetadata?: (runnerId: string) => { key: string; value: string }[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; } diff --git a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts index c6dd725742..b3fb63cdbe 100644 --- a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts +++ b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts @@ -3,6 +3,8 @@ export {}; declare global { namespace NodeJS { interface ProcessEnv { + SSM_CONFIG_PATH?: string; + SSM_CLEANUP_CONFIG?: string; SSM_PARAMETER_STORE_TAGS?: string; SSM_TOKEN_PATH?: string; } diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.test.ts new file mode 100644 index 0000000000..9c837c7fb7 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.test.ts @@ -0,0 +1,97 @@ +import { DeleteParameterCommand, GetParametersByPathCommand, SSMClient } from '@aws-sdk/client-ssm'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createAwsSsmRunnerConfigStore } from './runner-config-store'; + +const mockSSMClient = mockClient(SSMClient); +const cleanEnv = process.env; +const minimumDaysOld = 1; +const now = new Date(); +const oldDate = new Date(); +oldDate.setDate(oldDate.getDate() - minimumDaysOld - 1); +const tokenPath = '/path/to/tokens/'; + +describe('aws_ssm runner config housekeeper', () => { + beforeEach(() => { + mockSSMClient.reset(); + process.env = { ...cleanEnv }; + delete process.env.SSM_TOKEN_PATH; + process.env.AWS_REGION = 'eu-east-1'; + setCleanupOptions({ dryRun: false, minimumDaysOld, tokenPath }); + + mockSSMClient.on(GetParametersByPathCommand).resolves({ + Parameters: undefined, + }); + mockSSMClient.on(GetParametersByPathCommand, { Path: tokenPath }).resolves({ + Parameters: [ + { + Name: `${tokenPath}i-old-01`, + LastModifiedDate: oldDate, + }, + ], + NextToken: 'next', + }); + mockSSMClient.on(GetParametersByPathCommand, { Path: tokenPath, NextToken: 'next' }).resolves({ + Parameters: [ + { + Name: `${tokenPath}i-new-01`, + LastModifiedDate: now, + }, + ], + NextToken: undefined, + }); + }); + + it('constructs without writer configuration and deletes expired records across pages', async () => { + const store = createAwsSsmRunnerConfigStore(); + + await store.houseKeeper(); + + expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersByPathCommand, { Path: tokenPath }); + expect(mockSSMClient).toHaveReceivedCommandWith(DeleteParameterCommand, { Name: `${tokenPath}i-old-01` }); + expect(mockSSMClient).not.toHaveReceivedCommandWith(DeleteParameterCommand, { Name: `${tokenPath}i-new-01` }); + }); + + it('does not delete records during a dry run', async () => { + setCleanupOptions({ dryRun: true, minimumDaysOld, tokenPath }); + const store = createAwsSsmRunnerConfigStore(); + + await store.houseKeeper(); + + expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersByPathCommand, { Path: tokenPath }); + expect(mockSSMClient).not.toHaveReceivedCommand(DeleteParameterCommand); + }); + + it('does not delete when no records are found', async () => { + setCleanupOptions({ dryRun: false, minimumDaysOld, tokenPath: 'does-not-exist' }); + const store = createAwsSsmRunnerConfigStore(); + + await expect(store.houseKeeper()).resolves.not.toThrow(); + + expect(mockSSMClient).not.toHaveReceivedCommand(DeleteParameterCommand); + }); + + it('continues when deleting an expired record fails', async () => { + mockSSMClient.on(DeleteParameterCommand).rejects(new Error('ParameterNotFound')); + const store = createAwsSsmRunnerConfigStore(); + + await expect(store.houseKeeper()).resolves.not.toThrow(); + }); + + it.each([ + { dryRun: false, minimumDaysOld: undefined as unknown as number, tokenPath }, + { dryRun: false, minimumDaysOld: 0, tokenPath }, + { dryRun: false, minimumDaysOld, tokenPath: undefined as unknown as string }, + ])('rejects invalid cleanup options %#', async (options) => { + setCleanupOptions(options); + const store = createAwsSsmRunnerConfigStore(); + + await expect(store.houseKeeper()).rejects.toBeInstanceOf(Error); + }); +}); + +function setCleanupOptions(options: { dryRun: boolean; minimumDaysOld: number; tokenPath: string }): void { + process.env.SSM_CLEANUP_CONFIG = JSON.stringify(options); +} diff --git a/lambdas/functions/control-plane/src/scale-runners/ssm-housekeeper.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.ts similarity index 84% rename from lambdas/functions/control-plane/src/scale-runners/ssm-housekeeper.ts rename to lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.ts index 857b974a9d..2c0c22359c 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ssm-housekeeper.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.ts @@ -1,14 +1,13 @@ import { DeleteParameterCommand, GetParametersByPathCommand, SSMClient } from '@aws-sdk/client-ssm'; -import { logger } from '@aws-github-runner/aws-powertools-util'; -import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; +import { getTracedAWSV3Client, logger } from '@aws-github-runner/aws-powertools-util'; -export interface SSMCleanupOptions { +export interface SsmRunnerConfigCleanupOptions { dryRun: boolean; minimumDaysOld: number; tokenPath: string; } -function validateOptions(options: SSMCleanupOptions): void { +function validateOptions(options: SsmRunnerConfigCleanupOptions): void { const errorMessages: string[] = []; if (!options.minimumDaysOld || options.minimumDaysOld < 1) { errorMessages.push(`minimumDaysOld must be greater then 0, value is set to "${options.minimumDaysOld}"`); @@ -21,7 +20,7 @@ function validateOptions(options: SSMCleanupOptions): void { } } -export async function cleanSSMTokens(options: SSMCleanupOptions): Promise { +export async function cleanSsmRunnerConfigs(options: SsmRunnerConfigCleanupOptions): Promise { logger.info(`Cleaning tokens / JIT config older then ${options.minimumDaysOld} days, dryRun: ${options.dryRun}`); logger.debug('Cleaning with options', { options }); validateOptions(options); @@ -36,7 +35,6 @@ export async function cleanSSMTokens(options: SSMCleanupOptions): Promise parameters.NextToken = nextParameters.NextToken; } logger.info(`Found #${parameters.Parameters?.length} parameters in path ${options.tokenPath}`); - logger.debug('Found parameters', { parameters }); // minimumDate = today - minimumDaysOld const minimumDate = new Date(); @@ -47,7 +45,7 @@ export async function cleanSSMTokens(options: SSMCleanupOptions): Promise logger.info(`Deleting parameter ${parameter.Name} with last modified date ${parameter.LastModifiedDate}`); try { if (!options.dryRun) { - // sleep 50ms to avoid rait limit + // sleep 50ms to avoid rate limit await new Promise((resolve) => setTimeout(resolve, 50)); await client.send(new DeleteParameterCommand({ Name: parameter.Name })); } diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts index eaacff2718..04ecdda05d 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.test.ts @@ -14,11 +14,12 @@ describe('aws_ssm runner config store', () => { beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; + delete process.env.SSM_CLEANUP_CONFIG; delete process.env.SSM_PARAMETER_STORE_TAGS; process.env.SSM_TOKEN_PATH = '/runner/tokens'; }); - it('creates a secure parameter at the legacy path with metadata tags before configured tags', async () => { + it('maps metadata to tags before configured SSM tags', async () => { process.env.SSM_PARAMETER_STORE_TAGS = JSON.stringify([ { Key: 'Environment', Value: 'test' }, { Key: 'Team', Value: 'actions' }, @@ -27,7 +28,7 @@ describe('aws_ssm runner config store', () => { await store.create( { runnerId: 'i-123', value: 'encoded-jit-config' }, - { metadataTags: [{ key: 'InstanceId', value: 'i-123' }] }, + { metadata: [{ key: 'InstanceId', value: 'i-123' }] }, ); expect(store.maxWritesPerSecond).toBe(40); @@ -77,6 +78,12 @@ describe('aws_ssm runner config store', () => { expect(putParameterMock).toHaveBeenCalledWith('/runner/tokens/runner-1', 'jit-config', true, { tags: [] }); }); + + it.each(['', '{invalid-json'])('parses cleanup configuration %j during provider construction', (config) => { + process.env.SSM_CLEANUP_CONFIG = config; + + expect(() => createAwsSsmRunnerConfigStore()).toThrow(); + }); }); function setTokenPath(tokenPath: string | undefined): void { diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts index 179bcfa87c..dec2241086 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts @@ -1,23 +1,32 @@ import { putParameter } from '@aws-github-runner/aws-ssm-util'; -import type { RunnerConfigMetadataTag, RunnerConfigRecord, RunnerConfigStore } from '../../core'; +import type { RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore } from '../../core'; import type {} from './environment'; import { loadSsmParameterStoreTagsFromEnvironment } from './parameter-store-tags'; +import { cleanSsmRunnerConfigs, type SsmRunnerConfigCleanupOptions } from './runner-config-housekeeper'; interface AwsSsmRunnerConfigStoreConfig { - tokenPath: string; + tokenPath?: string; parameterStoreTags: { Key: string; Value: string }[]; + cleanupOptions?: SsmRunnerConfigCleanupOptions; } export function createAwsSsmRunnerConfigStore(): RunnerConfigStore { const tokenPath = process.env.SSM_TOKEN_PATH; - if (!tokenPath || tokenPath.trim() === '') { + const cleanupOptions = + process.env.SSM_CLEANUP_CONFIG !== undefined + ? (JSON.parse(process.env.SSM_CLEANUP_CONFIG) as SsmRunnerConfigCleanupOptions) + : undefined; + const hasWriterConfig = tokenPath !== undefined && tokenPath.trim() !== ''; + + if (!hasWriterConfig && cleanupOptions === undefined) { throw new Error('Environment variable SSM_TOKEN_PATH is not set'); } return new AwsSsmRunnerConfigStore({ - tokenPath, - parameterStoreTags: loadSsmParameterStoreTagsFromEnvironment(), + tokenPath: hasWriterConfig ? tokenPath : undefined, + parameterStoreTags: hasWriterConfig ? loadSsmParameterStoreTagsFromEnvironment() : [], + cleanupOptions, }); } @@ -26,12 +35,24 @@ class AwsSsmRunnerConfigStore implements RunnerConfigStore { constructor(private readonly config: AwsSsmRunnerConfigStoreConfig) {} - async create(record: RunnerConfigRecord, options: { metadataTags?: RunnerConfigMetadataTag[] } = {}): Promise { + async create(record: RunnerConfigRecord, options: { metadata?: RunnerConfigMetadata[] } = {}): Promise { + if (!this.config.tokenPath) { + throw new Error('Environment variable SSM_TOKEN_PATH is not set'); + } + await putParameter(`${this.config.tokenPath}/${record.runnerId}`, record.value, true, { tags: [ - ...(options.metadataTags ?? []).map(({ key, value }) => ({ Key: key, Value: value })), + ...(options.metadata ?? []).map(({ key, value }) => ({ Key: key, Value: value })), ...this.config.parameterStoreTags, ], }); } + + async houseKeeper(): Promise { + if (!this.config.cleanupOptions) { + throw new Error('Environment variable SSM_CLEANUP_CONFIG is not set'); + } + + await cleanSsmRunnerConfigs(this.config.cleanupOptions); + } } diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts new file mode 100644 index 0000000000..3943d5a401 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts @@ -0,0 +1,72 @@ +import { getParameter, putParameter } from '@aws-github-runner/aws-ssm-util'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmRunnerGroupCacheStore } from './runner-group-cache-store'; + +vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + getParameter: vi.fn(), + putParameter: vi.fn(), +})); + +const getParameterMock = vi.mocked(getParameter); +const putParameterMock = vi.mocked(putParameter); +const cleanEnv = process.env; + +describe('aws_ssm runner group cache store', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.SSM_PARAMETER_STORE_TAGS; + process.env.SSM_CONFIG_PATH = '/runner/config'; + }); + + it('gets and parses a runner group id from the legacy path', async () => { + getParameterMock.mockResolvedValue('42'); + const store = createAwsSsmRunnerGroupCacheStore(); + + await expect(store.get('Default')).resolves.toBe(42); + expect(getParameterMock).toHaveBeenCalledWith('/runner/config/runner-group/Default'); + }); + + it('preserves the previous parseInt behavior for cached values', async () => { + getParameterMock.mockResolvedValue('42cached'); + const store = createAwsSsmRunnerGroupCacheStore(); + + await expect(store.get('Default')).resolves.toBe(42); + }); + + it('propagates cache read errors', async () => { + const error = new Error('not found'); + getParameterMock.mockRejectedValue(error); + const store = createAwsSsmRunnerGroupCacheStore(); + + await expect(store.get('Default')).rejects.toBe(error); + }); + + it('creates a plaintext parameter at the legacy path with configured tags', async () => { + process.env.SSM_PARAMETER_STORE_TAGS = JSON.stringify([{ Key: 'Environment', Value: 'test' }]); + const store = createAwsSsmRunnerGroupCacheStore(); + + await store.create({ runnerGroupName: 'Default', runnerGroupId: 42 }); + + expect(putParameterMock).toHaveBeenCalledWith('/runner/config/runner-group/Default', '42', false, { + tags: [{ Key: 'Environment', Value: 'test' }], + }); + }); + + it.each([undefined, '', ' '])('rejects missing or blank SSM_CONFIG_PATH %j', (configPath) => { + setConfigPath(configPath); + + expect(() => createAwsSsmRunnerGroupCacheStore()).toThrow('Environment variable SSM_CONFIG_PATH is not set'); + expect(getParameterMock).not.toHaveBeenCalled(); + expect(putParameterMock).not.toHaveBeenCalled(); + }); +}); + +function setConfigPath(configPath: string | undefined): void { + if (configPath === undefined) { + delete process.env.SSM_CONFIG_PATH; + } else { + process.env.SSM_CONFIG_PATH = configPath; + } +} diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts new file mode 100644 index 0000000000..f79ee1b9ed --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts @@ -0,0 +1,41 @@ +import { getParameter, putParameter } from '@aws-github-runner/aws-ssm-util'; + +import type { RunnerGroupCacheRecord, RunnerGroupCacheStore } from '../../core'; +import type {} from './environment'; +import { loadSsmParameterStoreTagsFromEnvironment } from './parameter-store-tags'; + +interface AwsSsmRunnerGroupCacheStoreConfig { + configPath: string; + parameterStoreTags: { Key: string; Value: string }[]; +} + +export function createAwsSsmRunnerGroupCacheStore(): RunnerGroupCacheStore { + const configPath = process.env.SSM_CONFIG_PATH; + if (!configPath || configPath.trim() === '') { + throw new Error('Environment variable SSM_CONFIG_PATH is not set'); + } + + return new AwsSsmRunnerGroupCacheStore({ + configPath, + parameterStoreTags: loadSsmParameterStoreTagsFromEnvironment(), + }); +} + +class AwsSsmRunnerGroupCacheStore implements RunnerGroupCacheStore { + constructor(private readonly config: AwsSsmRunnerGroupCacheStoreConfig) {} + + async get(runnerGroupName: string): Promise { + const runnerGroupId = await getParameter(this.parameterName(runnerGroupName)); + return parseInt(runnerGroupId); + } + + async create(record: RunnerGroupCacheRecord): Promise { + await putParameter(this.parameterName(record.runnerGroupName), record.runnerGroupId.toString(), false, { + tags: this.config.parameterStoreTags, + }); + } + + private parameterName(runnerGroupName: string): string { + return `${this.config.configPath}/runner-group/${runnerGroupName}`; + } +} diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts index 7f9df413c0..6fba06f035 100644 --- a/lambdas/libs/storage-providers/core/index.ts +++ b/lambdas/libs/storage-providers/core/index.ts @@ -1,4 +1,4 @@ -export interface RunnerConfigMetadataTag { +export interface RunnerConfigMetadata { key: string; value: string; } @@ -10,5 +10,16 @@ export interface RunnerConfigRecord { export interface RunnerConfigStore { readonly maxWritesPerSecond?: number; - create(record: RunnerConfigRecord, options?: { metadataTags?: RunnerConfigMetadataTag[] }): Promise; + create(record: RunnerConfigRecord, options?: { metadata?: RunnerConfigMetadata[] }): Promise; + houseKeeper(): Promise; +} + +export interface RunnerGroupCacheRecord { + runnerGroupName: string; + runnerGroupId: number; +} + +export interface RunnerGroupCacheStore { + get(runnerGroupName: string): Promise; + create(record: RunnerGroupCacheRecord): Promise; } diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index 862924118b..8001457df5 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -1,2 +1,9 @@ -export type { RunnerConfigMetadataTag, RunnerConfigRecord, RunnerConfigStore } from './core'; +export type { + RunnerConfigMetadata, + RunnerConfigRecord, + RunnerConfigStore, + RunnerGroupCacheRecord, + RunnerGroupCacheStore, +} from './core'; export { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; +export { getRunnerGroupCacheStore, resetRunnerGroupCacheStore } from './runner-group-cache'; diff --git a/lambdas/libs/storage-providers/package.json b/lambdas/libs/storage-providers/package.json index 2b753fc436..745d026851 100644 --- a/lambdas/libs/storage-providers/package.json +++ b/lambdas/libs/storage-providers/package.json @@ -15,8 +15,14 @@ "format-check": "prettier --check \"**/*.ts\"", "all": "yarn format && yarn lint && yarn test" }, + "devDependencies": { + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0" + }, "dependencies": { - "@aws-github-runner/aws-ssm-util": "*" + "@aws-github-runner/aws-powertools-util": "*", + "@aws-github-runner/aws-ssm-util": "*", + "@aws-sdk/client-ssm": "^3.1009.0" }, "nx": { "includedScripts": [ diff --git a/lambdas/libs/storage-providers/provider.ts b/lambdas/libs/storage-providers/provider.ts new file mode 100644 index 0000000000..82b2c7895b --- /dev/null +++ b/lambdas/libs/storage-providers/provider.ts @@ -0,0 +1,26 @@ +export const runnerConfigStorageProviders = ['aws_ssm'] as const; + +export type RunnerConfigStorageProvider = (typeof runnerConfigStorageProviders)[number]; + +const defaultProvider = 'aws_ssm' satisfies RunnerConfigStorageProvider; + +export function resolveRunnerConfigStorageProvider(provider: unknown): RunnerConfigStorageProvider { + if (provider === undefined) { + return defaultProvider; + } + + if (typeof provider !== 'string') { + throw new Error(`Unsupported runner config storage provider '${String(provider)}'`); + } + + const normalizedProvider = provider.trim().toLowerCase(); + if (normalizedProvider === '') { + return defaultProvider; + } + + if (!runnerConfigStorageProviders.includes(normalizedProvider as RunnerConfigStorageProvider)) { + throw new Error(`Unsupported runner config storage provider '${String(provider)}'`); + } + + return normalizedProvider as RunnerConfigStorageProvider; +} diff --git a/lambdas/libs/storage-providers/runner-config.test.ts b/lambdas/libs/storage-providers/runner-config.test.ts index 10467ebdb4..95ba3787dd 100644 --- a/lambdas/libs/storage-providers/runner-config.test.ts +++ b/lambdas/libs/storage-providers/runner-config.test.ts @@ -60,7 +60,7 @@ describe('runner config store selection', () => { const firstStore = stubStore(); expect(getRunnerConfigStore()).toBe(firstStore); - const secondStore = { create: vi.fn() } satisfies RunnerConfigStore; + const secondStore = { create: vi.fn(), houseKeeper: vi.fn() } satisfies RunnerConfigStore; createAwsSsmRunnerConfigStoreMock.mockReturnValue(secondStore); resetRunnerConfigStore(); @@ -78,7 +78,7 @@ function setProvider(provider: string | undefined): void { } function stubStore(): RunnerConfigStore { - const store = { create: vi.fn() } satisfies RunnerConfigStore; + const store = { create: vi.fn(), houseKeeper: vi.fn() } satisfies RunnerConfigStore; createAwsSsmRunnerConfigStoreMock.mockReturnValue(store); return store; } diff --git a/lambdas/libs/storage-providers/runner-config.ts b/lambdas/libs/storage-providers/runner-config.ts index dfac2fcfb1..180c808d78 100644 --- a/lambdas/libs/storage-providers/runner-config.ts +++ b/lambdas/libs/storage-providers/runner-config.ts @@ -1,21 +1,19 @@ import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; import type { RunnerConfigStore } from './core'; import type {} from './environment'; +import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; type RunnerConfigStoreFactory = () => RunnerConfigStore; const providerFactories = { aws_ssm: createAwsSsmRunnerConfigStore, -} as const satisfies Record; - -type RunnerConfigStorageProvider = keyof typeof providerFactories; - -const defaultProvider = 'aws_ssm' satisfies RunnerConfigStorageProvider; +} as const satisfies Record; let runnerConfigStore: RunnerConfigStore | undefined; export function getRunnerConfigStore(): RunnerConfigStore { - runnerConfigStore ??= providerFactories[resolveProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + runnerConfigStore ??= + providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); return runnerConfigStore; } @@ -23,24 +21,3 @@ export function getRunnerConfigStore(): RunnerConfigStore { export function resetRunnerConfigStore(): void { runnerConfigStore = undefined; } - -function resolveProvider(provider: unknown): RunnerConfigStorageProvider { - if (provider === undefined) { - return defaultProvider; - } - - if (typeof provider !== 'string') { - throw new Error(`Unsupported runner config storage provider '${String(provider)}'`); - } - - const normalizedProvider = provider.trim().toLowerCase(); - if (normalizedProvider === '') { - return defaultProvider; - } - - if (!Object.prototype.hasOwnProperty.call(providerFactories, normalizedProvider)) { - throw new Error(`Unsupported runner config storage provider '${String(provider)}'`); - } - - return normalizedProvider as RunnerConfigStorageProvider; -} diff --git a/lambdas/libs/storage-providers/runner-group-cache.test.ts b/lambdas/libs/storage-providers/runner-group-cache.test.ts new file mode 100644 index 0000000000..e67e307905 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-group-cache.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmRunnerGroupCacheStore } from './aws/ssm/runner-group-cache-store'; +import type { RunnerGroupCacheStore } from './core'; +import { getRunnerGroupCacheStore, resetRunnerGroupCacheStore } from './runner-group-cache'; + +vi.mock('./aws/ssm/runner-group-cache-store', () => ({ + createAwsSsmRunnerGroupCacheStore: vi.fn(), +})); + +const createAwsSsmRunnerGroupCacheStoreMock = vi.mocked(createAwsSsmRunnerGroupCacheStore); +const cleanEnv = process.env; + +describe('runner group cache store selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + resetRunnerGroupCacheStore(); + }); + + it.each([undefined, '', ' '])('uses aws_ssm for default selector input %j', (provider) => { + setProvider(provider); + const store = stubStore(); + + expect(getRunnerGroupCacheStore()).toBe(store); + expect(createAwsSsmRunnerGroupCacheStoreMock).toHaveBeenCalledOnce(); + }); + + it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubStore(); + + expect(getRunnerGroupCacheStore()).toBe(store); + expect(createAwsSsmRunnerGroupCacheStoreMock).toHaveBeenCalledOnce(); + }); + + it('rejects an unsupported provider on first use', () => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + + expect(() => getRunnerGroupCacheStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsSsmRunnerGroupCacheStoreMock).not.toHaveBeenCalled(); + }); + + it('selects lazily and caches the created store', () => { + const store = stubStore(); + + expect(createAwsSsmRunnerGroupCacheStoreMock).not.toHaveBeenCalled(); + const first = getRunnerGroupCacheStore(); + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + const second = getRunnerGroupCacheStore(); + + expect(first).toBe(store); + expect(second).toBe(store); + expect(createAwsSsmRunnerGroupCacheStoreMock).toHaveBeenCalledOnce(); + }); + + it('selects again after the test reset', () => { + const firstStore = stubStore(); + expect(getRunnerGroupCacheStore()).toBe(firstStore); + + const secondStore = { get: vi.fn(), create: vi.fn() } satisfies RunnerGroupCacheStore; + createAwsSsmRunnerGroupCacheStoreMock.mockReturnValue(secondStore); + resetRunnerGroupCacheStore(); + + expect(getRunnerGroupCacheStore()).toBe(secondStore); + expect(createAwsSsmRunnerGroupCacheStoreMock).toHaveBeenCalledTimes(2); + }); +}); + +function setProvider(provider: string | undefined): void { + if (provider === undefined) { + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + } else { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + } +} + +function stubStore(): RunnerGroupCacheStore { + const store = { get: vi.fn(), create: vi.fn() } satisfies RunnerGroupCacheStore; + createAwsSsmRunnerGroupCacheStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/runner-group-cache.ts b/lambdas/libs/storage-providers/runner-group-cache.ts new file mode 100644 index 0000000000..a28b00f48e --- /dev/null +++ b/lambdas/libs/storage-providers/runner-group-cache.ts @@ -0,0 +1,23 @@ +import { createAwsSsmRunnerGroupCacheStore } from './aws/ssm/runner-group-cache-store'; +import type { RunnerGroupCacheStore } from './core'; +import type {} from './environment'; +import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; + +type RunnerGroupCacheStoreFactory = () => RunnerGroupCacheStore; + +const providerFactories = { + aws_ssm: createAwsSsmRunnerGroupCacheStore, +} as const satisfies Record; + +let runnerGroupCacheStore: RunnerGroupCacheStore | undefined; + +export function getRunnerGroupCacheStore(): RunnerGroupCacheStore { + runnerGroupCacheStore ??= + providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + return runnerGroupCacheStore; +} + +// Test-only reset for cases that need to exercise first-use environment selection. +export function resetRunnerGroupCacheStore(): void { + runnerGroupCacheStore = undefined; +} diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts index a5812ad13e..af85b8946d 100644 --- a/lambdas/libs/storage-providers/vitest.config.ts +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -7,7 +7,7 @@ export default mergeConfig(defaultConfig, { test: { setupFiles: [resolve(__dirname, '../../aws-vitest-setup.ts')], coverage: { - include: ['index.ts', 'runner-config.ts', 'core/**/*.ts', 'aws/**/*.ts'], + include: ['index.ts', 'provider.ts', 'runner-config.ts', 'runner-group-cache.ts', 'core/**/*.ts', 'aws/**/*.ts'], exclude: ['**/*.test.ts', '**/*.d.ts'], }, }, diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 1425694615..09a7c33df5 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -214,7 +214,11 @@ __metadata: version: 0.0.0-use.local resolution: "@aws-github-runner/storage-providers@workspace:libs/storage-providers" dependencies: + "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-sdk/client-ssm": "npm:^3.1009.0" + aws-sdk-client-mock: "npm:^4.1.0" + aws-sdk-client-mock-jest: "npm:^4.1.0" languageName: unknown linkType: soft From ddaeedcf3cda44c49e8d18011e36fe9e07de8528 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 18 Aug 2026 23:43:43 +0200 Subject: [PATCH 39/49] refactor(storage): move local housekeeper harness --- .../aws/ssm/local-runner-config-housekeeper.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename lambdas/{functions/control-plane/src/local-ssm-housekeeper.ts => libs/storage-providers/aws/ssm/local-runner-config-housekeeper.ts} (71%) diff --git a/lambdas/functions/control-plane/src/local-ssm-housekeeper.ts b/lambdas/libs/storage-providers/aws/ssm/local-runner-config-housekeeper.ts similarity index 71% rename from lambdas/functions/control-plane/src/local-ssm-housekeeper.ts rename to lambdas/libs/storage-providers/aws/ssm/local-runner-config-housekeeper.ts index 08b062a193..79518a8157 100644 --- a/lambdas/functions/control-plane/src/local-ssm-housekeeper.ts +++ b/lambdas/libs/storage-providers/aws/ssm/local-runner-config-housekeeper.ts @@ -1,4 +1,4 @@ -import { getRunnerConfigStore } from '@aws-github-runner/storage-providers'; +import { createAwsSsmRunnerConfigStore } from './runner-config-store'; export function run(): void { process.env.SSM_CLEANUP_CONFIG = JSON.stringify({ @@ -7,7 +7,7 @@ export function run(): void { tokenPath: '/ghr/my-env/runners/tokens', }); - getRunnerConfigStore() + createAwsSsmRunnerConfigStore() .houseKeeper() .then() .catch((e) => { From 77f78e680bd2780050acad606151d51885f775f8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 18 Aug 2026 23:48:43 +0200 Subject: [PATCH 40/49] refactor(storage): preserve SSM cleanup names --- .../aws/ssm/runner-config-housekeeper.ts | 6 +++--- .../libs/storage-providers/aws/ssm/runner-config-store.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.ts index 2c0c22359c..30bc1d20ca 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-housekeeper.ts @@ -1,13 +1,13 @@ import { DeleteParameterCommand, GetParametersByPathCommand, SSMClient } from '@aws-sdk/client-ssm'; import { getTracedAWSV3Client, logger } from '@aws-github-runner/aws-powertools-util'; -export interface SsmRunnerConfigCleanupOptions { +export interface SSMCleanupOptions { dryRun: boolean; minimumDaysOld: number; tokenPath: string; } -function validateOptions(options: SsmRunnerConfigCleanupOptions): void { +function validateOptions(options: SSMCleanupOptions): void { const errorMessages: string[] = []; if (!options.minimumDaysOld || options.minimumDaysOld < 1) { errorMessages.push(`minimumDaysOld must be greater then 0, value is set to "${options.minimumDaysOld}"`); @@ -20,7 +20,7 @@ function validateOptions(options: SsmRunnerConfigCleanupOptions): void { } } -export async function cleanSsmRunnerConfigs(options: SsmRunnerConfigCleanupOptions): Promise { +export async function cleanSSMTokens(options: SSMCleanupOptions): Promise { logger.info(`Cleaning tokens / JIT config older then ${options.minimumDaysOld} days, dryRun: ${options.dryRun}`); logger.debug('Cleaning with options', { options }); validateOptions(options); diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts index dec2241086..1959a6192a 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts @@ -3,19 +3,19 @@ import { putParameter } from '@aws-github-runner/aws-ssm-util'; import type { RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore } from '../../core'; import type {} from './environment'; import { loadSsmParameterStoreTagsFromEnvironment } from './parameter-store-tags'; -import { cleanSsmRunnerConfigs, type SsmRunnerConfigCleanupOptions } from './runner-config-housekeeper'; +import { cleanSSMTokens, type SSMCleanupOptions } from './runner-config-housekeeper'; interface AwsSsmRunnerConfigStoreConfig { tokenPath?: string; parameterStoreTags: { Key: string; Value: string }[]; - cleanupOptions?: SsmRunnerConfigCleanupOptions; + cleanupOptions?: SSMCleanupOptions; } export function createAwsSsmRunnerConfigStore(): RunnerConfigStore { const tokenPath = process.env.SSM_TOKEN_PATH; const cleanupOptions = process.env.SSM_CLEANUP_CONFIG !== undefined - ? (JSON.parse(process.env.SSM_CLEANUP_CONFIG) as SsmRunnerConfigCleanupOptions) + ? (JSON.parse(process.env.SSM_CLEANUP_CONFIG) as SSMCleanupOptions) : undefined; const hasWriterConfig = tokenPath !== undefined && tokenPath.trim() !== ''; @@ -53,6 +53,6 @@ class AwsSsmRunnerConfigStore implements RunnerConfigStore { throw new Error('Environment variable SSM_CLEANUP_CONFIG is not set'); } - await cleanSsmRunnerConfigs(this.config.cleanupOptions); + await cleanSSMTokens(this.config.cleanupOptions); } } From 0ac623dc697f08f68bd227ae98abc6aad3e95dad Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 00:09:49 +0200 Subject: [PATCH 41/49] test(storage): decouple scale-up tests from SSM --- .../src/scale-runners/scale-up.test.ts | 508 +++++++----------- lambdas/libs/aws-ssm-util/src/index.test.ts | 21 + 2 files changed, 217 insertions(+), 312 deletions(-) diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 0f86fefc10..4e90321f30 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -1,9 +1,12 @@ -import { PutParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; -import { mockClient } from 'aws-sdk-client-mock'; -import 'aws-sdk-client-mock-jest/vitest'; -// Using vi.mocked instead of jest-mock +import { + getRunnerConfigStore, + getRunnerGroupCacheStore, + type RunnerConfigStore, + type RunnerGroupCacheStore, +} from '@aws-github-runner/storage-providers'; +import type { Octokit } from '@octokit/rest'; import nock from 'nock'; -import { performance } from 'perf_hooks'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { controlPlaneProviderRegistry } from '../control-plane-providers'; import * as ghAuth from '../github/auth'; @@ -16,10 +19,6 @@ import type { CreateScaleUpRunnersInput, ScaleUpComputeProvider, } from './types'; -import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { resetRunnerConfigStore, resetRunnerGroupCacheStore } from '@aws-github-runner/storage-providers'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Octokit } from '@octokit/rest'; const mockOctokit = { paginate: vi.fn(), @@ -54,8 +53,21 @@ const createRunner = vi.fn<(input: TestRunnerCreationInput) => Promise Promise>(); const mockCreateRunner = vi.mocked(createRunner); const mockListRunners = vi.mocked(listRunners); -const mockSSMClient = mockClient(SSMClient); -const mockSSMgetParameter = vi.mocked(getParameter); +const mockGetRunnerConfigStore = vi.mocked(getRunnerConfigStore); +const mockGetRunnerGroupCacheStore = vi.mocked(getRunnerGroupCacheStore); +const mockRunnerConfigCreate = vi.fn(); +const mockRunnerConfigHouseKeeper = vi.fn(); +const mockRunnerGroupCacheGet = vi.fn(); +const mockRunnerGroupCacheCreate = vi.fn(); +const mockRunnerConfigStore: RunnerConfigStore = { + maxWritesPerSecond: 40, + create: mockRunnerConfigCreate, + houseKeeper: mockRunnerConfigHouseKeeper, +}; +const mockRunnerGroupCacheStore: RunnerGroupCacheStore = { + get: mockRunnerGroupCacheGet, + create: mockRunnerGroupCacheCreate, +}; const mockPublishRetryMessage = vi.mocked(publishRetryMessage); const testProviderState = { provider: 'test' }; const mockComputeProvider: ScaleUpComputeProvider = { @@ -87,16 +99,10 @@ vi.mock('../github/auth', async () => ({ getStoredInstallationId: vi.fn().mockResolvedValue(undefined), })); -vi.mock('@aws-github-runner/aws-ssm-util', async () => { - const actual = (await vi.importActual( - '@aws-github-runner/aws-ssm-util', - )) as typeof import('@aws-github-runner/aws-ssm-util'); - - return { - ...actual, - getParameter: vi.fn(), - }; -}); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getRunnerConfigStore: vi.fn(), + getRunnerGroupCacheStore: vi.fn(), +})); vi.mock('./job-retry', () => ({ publishRetryMessage: vi.fn(), @@ -148,8 +154,6 @@ function setDefaults() { process.env.GITHUB_APP_CLIENT_SECRET = 'TEST_CLIENT_SECRET'; process.env.RUNNERS_MAXIMUM_COUNT = '3'; process.env.ENVIRONMENT = EXPECTED_RUNNER_PARAMS.environment; - process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; } async function createTestProviderRunners(input: CreateScaleUpRunnersInput): Promise { @@ -190,10 +194,12 @@ beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); setDefaults(); - resetRunnerConfigStore(); - resetRunnerGroupCacheStore(); - - defaultSSMGetParameterMockImpl(); + mockGetRunnerConfigStore.mockReturnValue(mockRunnerConfigStore); + mockGetRunnerGroupCacheStore.mockReturnValue(mockRunnerGroupCacheStore); + mockRunnerConfigCreate.mockResolvedValue(); + mockRunnerConfigHouseKeeper.mockResolvedValue(); + mockRunnerGroupCacheGet.mockResolvedValue(1); + mockRunnerGroupCacheCreate.mockResolvedValue(); defaultOctokitMockImpl(); mockedResolveCapability.mockReturnValue(() => mockComputeProvider); @@ -273,12 +279,9 @@ describe('scaleUp with GHES', () => { process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.RUNNER_NAME_PREFIX = 'unit-test-'; process.env.RUNNER_GROUP_NAME = 'Default'; - process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; process.env.RUNNER_LABELS = 'label1,label2'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; - mockSSMClient.reset(); }); it('does not create a token when maximum runners has been reached', async () => { @@ -333,9 +336,7 @@ describe('scaleUp with GHES', () => { it('returns a retryable failure if runner group lookup fails for ephemeral runners', async () => { process.env.RUNNER_GROUP_NAME = 'test-runner-group'; - mockSSMgetParameter.mockImplementation(async () => { - throw new Error('ParameterNotFound'); - }); + mockRunnerGroupCacheGet.mockRejectedValue(new Error('Cache entry not found')); await expect(scaleUpModule.scaleUp(TEST_DATA)).resolves.toEqual(['foobar']); @@ -350,24 +351,27 @@ describe('scaleUp with GHES', () => { expect(createRunner).not.toHaveBeenCalled(); }); - it('create SSM parameter for runner group id if it does not exist', async () => { - mockSSMgetParameter.mockImplementation(async () => { - throw new Error('ParameterNotFound'); - }); + it('caches the runner group id if it does not exist', async () => { + mockRunnerGroupCacheGet.mockResolvedValue(undefined); + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerGroupCacheGet).toHaveBeenCalledWith('Default'); expect(mockOctokit.paginate).toHaveBeenCalledTimes(1); - expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 2); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: `${process.env.SSM_CONFIG_PATH}/runner-group/${process.env.RUNNER_GROUP_NAME}`, - Value: '1', - Type: 'String', + expect(mockRunnerGroupCacheCreate).toHaveBeenCalledWith({ + runnerGroupName: 'Default', + runnerGroupId: 1, }); + expect(mockRunnerConfigCreate).toHaveBeenCalledTimes(1); }); - it('Does not create SSM parameter for runner group id if it exists', async () => { + it('reuses a cached runner group id', async () => { await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerGroupCacheGet).toHaveBeenCalledWith('Default'); expect(mockOctokit.paginate).toHaveBeenCalledTimes(0); - expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 1); + expect(mockRunnerGroupCacheCreate).not.toHaveBeenCalled(); + expect(mockRunnerConfigCreate).toHaveBeenCalledTimes(1); }); it('create start runner config for ephemeral runners ', async () => { @@ -380,17 +384,10 @@ describe('scaleUp with GHES', () => { runner_group_id: 1, labels: ['label1', 'label2'], }); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: 'TEST_JIT_CONFIG_ORG', - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { runnerId: 'i-12345', value: 'TEST_JIT_CONFIG_ORG' }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); }); it('create start runner config for non-ephemeral runners ', async () => { @@ -399,19 +396,15 @@ describe('scaleUp with GHES', () => { await scaleUpModule.scaleUp(TEST_DATA); expect(mockOctokit.actions.generateRunnerJitconfigForOrg).not.toBeCalled(); expect(mockOctokit.actions.createRegistrationTokenForOrg).toBeCalled(); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: - '--url https://github.enterprise.something/Codertocat --token 1234abcd ' + - '--labels label1,label2 --runnergroup Default', - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { + runnerId: 'i-12345', + value: + '--url https://github.enterprise.something/Codertocat --token 1234abcd ' + + '--labels label1,label2 --runnergroup Default', + }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); }); it('quotes runner labels with semicolon separators in non-ephemeral runner config', async () => { @@ -426,19 +419,15 @@ describe('scaleUp with GHES', () => { }, ]); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: - '--url https://github.enterprise.something/Codertocat --token 1234abcd ' + - "--labels 'label1,label2,ghr-provider-capability:intel;amd' --runnergroup Default", - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { + runnerId: 'i-12345', + value: + '--url https://github.enterprise.something/Codertocat --token 1234abcd ' + + "--labels 'label1,label2,ghr-provider-capability:intel;amd' --runnergroup Default", + }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); }); it('should create JIT config for all remaining instances even when GitHub API fails for one instance', async () => { @@ -498,23 +487,18 @@ describe('scaleUp with GHES', () => { labels: ['label1', 'label2'], }); - expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-instance-1', - Value: 'TEST_JIT_CONFIG_unit-test-i-instance-1', - Type: 'SecureString', - Tags: [{ Key: 'RunnerId', Value: 'i-instance-1' }], - }); - - expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-instance-3', - Value: 'TEST_JIT_CONFIG_unit-test-i-instance-3', - Type: 'SecureString', - Tags: [{ Key: 'RunnerId', Value: 'i-instance-3' }], - }); - - expect(mockSSMClient).not.toHaveReceivedCommandWith(PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-instance-2', - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { runnerId: 'i-instance-1', value: 'TEST_JIT_CONFIG_unit-test-i-instance-1' }, + { metadata: [{ key: 'RunnerId', value: 'i-instance-1' }] }, + ); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { runnerId: 'i-instance-3', value: 'TEST_JIT_CONFIG_unit-test-i-instance-3' }, + { metadata: [{ key: 'RunnerId', value: 'i-instance-3' }] }, + ); + expect(mockRunnerConfigCreate).not.toHaveBeenCalledWith( + expect.objectContaining({ runnerId: 'i-instance-2' }), + expect.anything(), + ); }); it('should handle retryable errors with error handling logic', async () => { @@ -550,16 +534,14 @@ describe('scaleUp with GHES', () => { await scaleUpModule.scaleUp(TEST_DATA); - expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-instance-2', - Value: 'TEST_JIT_CONFIG_unit-test-i-instance-2', - Type: 'SecureString', - Tags: [{ Key: 'RunnerId', Value: 'i-instance-2' }], - }); - - expect(mockSSMClient).not.toHaveReceivedCommandWith(PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-instance-1', - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { runnerId: 'i-instance-2', value: 'TEST_JIT_CONFIG_unit-test-i-instance-2' }, + { metadata: [{ key: 'RunnerId', value: 'i-instance-2' }] }, + ); + expect(mockRunnerConfigCreate).not.toHaveBeenCalledWith( + expect.objectContaining({ runnerId: 'i-instance-1' }), + expect.anything(), + ); }); it('should handle non-retryable 4xx errors gracefully', async () => { @@ -596,79 +578,62 @@ describe('scaleUp with GHES', () => { await scaleUpModule.scaleUp(TEST_DATA); - expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-instance-2', - Value: 'TEST_JIT_CONFIG_unit-test-i-instance-2', - Type: 'SecureString', - Tags: [{ Key: 'RunnerId', Value: 'i-instance-2' }], - }); - - expect(mockSSMClient).not.toHaveReceivedCommandWith(PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-instance-1', - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { runnerId: 'i-instance-2', value: 'TEST_JIT_CONFIG_unit-test-i-instance-2' }, + { metadata: [{ key: 'RunnerId', value: 'i-instance-2' }] }, + ); + expect(mockRunnerConfigCreate).not.toHaveBeenCalledWith( + expect.objectContaining({ runnerId: 'i-instance-1' }), + expect.anything(), + ); }); it.each(RUNNER_TYPES)( - 'calls create start runner config of 40' + ' instances (ssm rate limit condition) to test time delay ', + 'paces 40 runner-config writes at the store throughput limit for %s runners', async (type: RunnerLifecycle) => { process.env.ENABLE_EPHEMERAL_RUNNERS = type === 'ephemeral' ? 'true' : 'false'; process.env.RUNNERS_MAXIMUM_COUNT = '40'; + const instances = Array.from({ length: 40 }, (_, index) => `i-${index + 1}`); mockCreateRunner.mockImplementation(async () => { return createRunnerResult(instances); }); mockListRunners.mockImplementation(async () => { return []; }); - const startTime = performance.now(); - const instances = [ - 'i-1234', - 'i-5678', - 'i-5567', - 'i-5569', - 'i-5561', - 'i-5560', - 'i-5566', - 'i-5536', - 'i-5526', - 'i-5516', - 'i-122', - 'i-123', - 'i-124', - 'i-125', - 'i-126', - 'i-127', - 'i-128', - 'i-129', - 'i-130', - 'i-131', - 'i-132', - 'i-133', - 'i-134', - 'i-135', - 'i-136', - 'i-137', - 'i-138', - 'i-139', - 'i-140', - 'i-141', - 'i-142', - 'i-143', - 'i-144', - 'i-145', - 'i-146', - 'i-147', - 'i-148', - 'i-149', - 'i-150', - 'i-151', - ]; - await scaleUpModule.scaleUp(TEST_DATA); - const endTime = performance.now(); - expect(endTime - startTime).toBeGreaterThan(1000); - expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 40); + + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((callback) => { + callback(); + return 0 as unknown as NodeJS.Timeout; + }); + try { + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerConfigCreate).toHaveBeenCalledTimes(40); + expect(setTimeoutSpy).toHaveBeenCalledTimes(40); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 25); + } finally { + setTimeoutSpy.mockRestore(); + } }, - 10000, ); + + it('does not pace 39 runner-config writes below the store throughput limit', async () => { + process.env.ENABLE_EPHEMERAL_RUNNERS = 'false'; + process.env.RUNNERS_MAXIMUM_COUNT = '39'; + const instances = Array.from({ length: 39 }, (_, index) => `i-${index + 1}`); + mockCreateRunner.mockResolvedValue(createRunnerResult(instances)); + mockListRunners.mockResolvedValue([]); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + + try { + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerConfigCreate).toHaveBeenCalledTimes(39); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + } + }); }); describe('dynamic label groups', () => { @@ -679,7 +644,6 @@ describe('scaleUp with GHES', () => { process.env.RUNNER_LABELS = 'base-label'; process.env.RUNNER_NAME_PREFIX = 'unit-test'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; - mockSSMClient.reset(); mockResolveLabelsForRunners.mockImplementation(async (labels) => ({ runnerLabels: labels.filter((label) => label.startsWith('ghr-')), @@ -1155,8 +1119,6 @@ describe('scaleUp with public GH', () => { describe('on repo level', () => { beforeEach(() => { - mockSSMClient.reset(); - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; process.env.RUNNER_NAME_PREFIX = 'unit-test'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; @@ -1200,45 +1162,32 @@ describe('scaleUp with public GH', () => { it('creates a ephemeral runner with JIT config.', async () => { process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.ENABLE_JOB_QUEUED_CHECK = 'false'; - delete process.env.SSM_CONFIG_PATH; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; await scaleUpModule.scaleUp(TEST_DATA); expect(mockOctokit.actions.getJobForWorkflowRun).not.toBeCalled(); expect(createRunner).toBeCalledWith(expectedRunnerParams); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: 'TEST_JIT_CONFIG_REPO', - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { runnerId: 'i-12345', value: 'TEST_JIT_CONFIG_REPO' }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); + expect(mockGetRunnerGroupCacheStore).not.toHaveBeenCalled(); }); it('creates a ephemeral runner with registration token.', async () => { process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.ENABLE_JIT_CONFIG = 'false'; process.env.ENABLE_JOB_QUEUED_CHECK = 'false'; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; await scaleUpModule.scaleUp(TEST_DATA); expect(mockOctokit.actions.getJobForWorkflowRun).not.toBeCalled(); expect(createRunner).toBeCalledWith(expectedRunnerParams); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: '--url https://github.com/Codertocat/hello-world --token 1234abcd --ephemeral', - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { + runnerId: 'i-12345', + value: '--url https://github.com/Codertocat/hello-world --token 1234abcd --ephemeral', + }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); }); it('JIT config is ignored for non-ephemeral runners.', async () => { @@ -1246,23 +1195,18 @@ describe('scaleUp with public GH', () => { process.env.ENABLE_JIT_CONFIG = 'true'; process.env.ENABLE_JOB_QUEUED_CHECK = 'false'; process.env.RUNNER_LABELS = 'jit'; - delete process.env.SSM_CONFIG_PATH; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; await scaleUpModule.scaleUp(TEST_DATA); expect(mockOctokit.actions.getJobForWorkflowRun).not.toBeCalled(); expect(createRunner).toBeCalledWith(expectedRunnerParams); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: '--url https://github.com/Codertocat/hello-world --token 1234abcd --labels jit', - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { + runnerId: 'i-12345', + value: '--url https://github.com/Codertocat/hello-world --token 1234abcd --labels jit', + }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); + expect(mockGetRunnerGroupCacheStore).not.toHaveBeenCalled(); }); it('creates a ephemeral runner after checking job is queued.', async () => { @@ -1541,12 +1485,9 @@ describe('scaleUp with Github Data Residency', () => { process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.RUNNER_NAME_PREFIX = 'unit-test-'; process.env.RUNNER_GROUP_NAME = 'Default'; - process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; process.env.RUNNER_LABELS = 'label1,label2'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; - mockSSMClient.reset(); }); it('does not create a token when maximum runners has been reached', async () => { @@ -1589,24 +1530,27 @@ describe('scaleUp with Github Data Residency', () => { expect(createRunner).not.toHaveBeenCalled(); }); - it('create SSM parameter for runner group id if it does not exist', async () => { - mockSSMgetParameter.mockImplementation(async () => { - throw new Error('ParameterNotFound'); - }); + it('caches the runner group id if it does not exist', async () => { + mockRunnerGroupCacheGet.mockResolvedValue(undefined); + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerGroupCacheGet).toHaveBeenCalledWith('Default'); expect(mockOctokit.paginate).toHaveBeenCalledTimes(1); - expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 2); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: `${process.env.SSM_CONFIG_PATH}/runner-group/${process.env.RUNNER_GROUP_NAME}`, - Value: '1', - Type: 'String', + expect(mockRunnerGroupCacheCreate).toHaveBeenCalledWith({ + runnerGroupName: 'Default', + runnerGroupId: 1, }); + expect(mockRunnerConfigCreate).toHaveBeenCalledTimes(1); }); - it('Does not create SSM parameter for runner group id if it exists', async () => { + it('reuses a cached runner group id', async () => { await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerGroupCacheGet).toHaveBeenCalledWith('Default'); expect(mockOctokit.paginate).toHaveBeenCalledTimes(0); - expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 1); + expect(mockRunnerGroupCacheCreate).not.toHaveBeenCalled(); + expect(mockRunnerConfigCreate).toHaveBeenCalledTimes(1); }); it('create start runner config for ephemeral runners ', async () => { @@ -1619,17 +1563,10 @@ describe('scaleUp with Github Data Residency', () => { runner_group_id: 1, labels: ['label1', 'label2'], }); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: 'TEST_JIT_CONFIG_ORG', - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { runnerId: 'i-12345', value: 'TEST_JIT_CONFIG_ORG' }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); }); it('create start runner config for non-ephemeral runners ', async () => { @@ -1638,80 +1575,43 @@ describe('scaleUp with Github Data Residency', () => { await scaleUpModule.scaleUp(TEST_DATA); expect(mockOctokit.actions.generateRunnerJitconfigForOrg).not.toBeCalled(); expect(mockOctokit.actions.createRegistrationTokenForOrg).toBeCalled(); - expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, { - Name: '/github-action-runners/default/runners/config/i-12345', - Value: - '--url https://companyname.ghe.com/Codertocat --token 1234abcd ' + - '--labels label1,label2 --runnergroup Default', - Type: 'SecureString', - Tags: [ - { - Key: 'RunnerId', - Value: 'i-12345', - }, - ], - }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { + runnerId: 'i-12345', + value: + '--url https://companyname.ghe.com/Codertocat --token 1234abcd ' + + '--labels label1,label2 --runnergroup Default', + }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); }); it.each(RUNNER_TYPES)( - 'calls create start runner config of 40' + ' instances (ssm rate limit condition) to test time delay ', + 'paces 40 runner-config writes at the store throughput limit for %s runners', async (type: RunnerLifecycle) => { process.env.ENABLE_EPHEMERAL_RUNNERS = type === 'ephemeral' ? 'true' : 'false'; process.env.RUNNERS_MAXIMUM_COUNT = '40'; + const instances = Array.from({ length: 40 }, (_, index) => `i-${index + 1}`); mockCreateRunner.mockImplementation(async () => { return createRunnerResult(instances); }); mockListRunners.mockImplementation(async () => { return []; }); - const startTime = performance.now(); - const instances = [ - 'i-1234', - 'i-5678', - 'i-5567', - 'i-5569', - 'i-5561', - 'i-5560', - 'i-5566', - 'i-5536', - 'i-5526', - 'i-5516', - 'i-122', - 'i-123', - 'i-124', - 'i-125', - 'i-126', - 'i-127', - 'i-128', - 'i-129', - 'i-130', - 'i-131', - 'i-132', - 'i-133', - 'i-134', - 'i-135', - 'i-136', - 'i-137', - 'i-138', - 'i-139', - 'i-140', - 'i-141', - 'i-142', - 'i-143', - 'i-144', - 'i-145', - 'i-146', - 'i-147', - 'i-148', - 'i-149', - 'i-150', - 'i-151', - ]; - await scaleUpModule.scaleUp(TEST_DATA); - const endTime = performance.now(); - expect(endTime - startTime).toBeGreaterThan(1000); - expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 40); + + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((callback) => { + callback(); + return 0 as unknown as NodeJS.Timeout; + }); + try { + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerConfigCreate).toHaveBeenCalledTimes(40); + expect(setTimeoutSpy).toHaveBeenCalledTimes(40); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 25); + } finally { + setTimeoutSpy.mockRestore(); + } }, - 10000, ); }); describe('on repo level', () => { @@ -1996,7 +1896,6 @@ describe('Retry mechanism tests', () => { process.env.ENABLE_JOB_QUEUED_CHECK = 'true'; process.env.RUNNERS_MAXIMUM_COUNT = '10'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; - mockSSMClient.reset(); }); const createTestMessages = ( @@ -2184,11 +2083,8 @@ describe('Multi-app round-robin', () => { process.env.RUNNERS_MAXIMUM_COUNT = '10'; process.env.RUNNER_NAME_PREFIX = 'unit-test-'; process.env.RUNNER_GROUP_NAME = 'Default'; - process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; process.env.RUNNER_LABELS = 'label1,label2'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; - mockSSMClient.reset(); }); it('passes the same appIndex to createGithubInstallationAuth when multi-app is active', async () => { @@ -2272,7 +2168,7 @@ describe('Multi-app round-robin', () => { }); it('stored installationId takes precedence over webhook payload for additional app', async () => { - // Additional app (index 1) with a pre-configured installation id stored in SSM + // Additional app (index 1) with a pre-configured installation id mockedGetAppCount.mockResolvedValue(2); mockedGetStoredInstallationId.mockResolvedValue(77); mockedAppAuth.mockResolvedValue({ @@ -2342,15 +2238,3 @@ function defaultOctokitMockImpl() { mockOctokit.apps.getOrgInstallation.mockImplementation(() => mockInstallationIdReturnValueOrgs); mockOctokit.apps.getRepoInstallation.mockImplementation(() => mockInstallationIdReturnValueRepos); } - -function defaultSSMGetParameterMockImpl() { - mockSSMgetParameter.mockImplementation(async (name: string) => { - if (name === `${process.env.SSM_CONFIG_PATH}/runner-group/${process.env.RUNNER_GROUP_NAME}`) { - return '1'; - } else if (name === `${process.env.PARAMETER_GITHUB_APP_ID_NAME}`) { - return `${process.env.GITHUB_APP_ID}`; - } else { - throw new Error(`ParameterNotFound: ${name}`); - } - }); -} diff --git a/lambdas/libs/aws-ssm-util/src/index.test.ts b/lambdas/libs/aws-ssm-util/src/index.test.ts index 8a1d8d3864..f027b16293 100644 --- a/lambdas/libs/aws-ssm-util/src/index.test.ts +++ b/lambdas/libs/aws-ssm-util/src/index.test.ts @@ -127,6 +127,27 @@ describe('Test getParameter and putParameter', () => { }); }); + it('passes tags to the PutParameter command', async () => { + const parameterValue = 'test'; + const parameterName = 'testParam'; + const tags = [{ Key: 'InstanceId', Value: 'i-123' }]; + const output: PutParameterCommandOutput = { + $metadata: { + httpStatusCode: 200, + }, + }; + mockSSMClient.on(PutParameterCommand).resolves(output); + + await putParameter(parameterName, parameterValue, true, { tags }); + + expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, { + Name: parameterName, + Value: parameterValue, + Type: 'SecureString', + Tags: tags, + }); + }); + it('Gets invalid parameters and returns string', async () => { // Arrange const parameterName = 'invalid'; From 4726813fd420306fe1956f53a6e4b7e6e2d3fea7 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 10:15:33 +0200 Subject: [PATCH 42/49] refactor(storage): extract GitHub App credentials --- lambdas/functions/control-plane/package.json | 1 - .../control-plane/src/github/auth.test.ts | 228 +++++------------- .../control-plane/src/github/auth.ts | 54 +---- .../src/github/rate-limit.test.ts | 181 ++++++-------- .../control-plane/src/github/rate-limit.ts | 16 +- .../control-plane/src/lambda.test.ts | 1 - .../functions/control-plane/src/modules.d.ts | 2 - .../src/scale-runners/scale-up.test.ts | 1 - .../aws/ssm/environment.d.ts | 3 + .../ssm/github-app-credentials-store.test.ts | 154 ++++++++++++ .../aws/ssm/github-app-credentials-store.ts | 61 +++++ lambdas/libs/storage-providers/core/index.ts | 10 + .../github-app-credentials.test.ts | 83 +++++++ .../github-app-credentials.ts | 23 ++ lambdas/libs/storage-providers/index.ts | 3 + .../libs/storage-providers/vitest.config.ts | 10 +- lambdas/yarn.lock | 1 - 17 files changed, 486 insertions(+), 346 deletions(-) create mode 100644 lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts create mode 100644 lambdas/libs/storage-providers/github-app-credentials.test.ts create mode 100644 lambdas/libs/storage-providers/github-app-credentials.ts diff --git a/lambdas/functions/control-plane/package.json b/lambdas/functions/control-plane/package.json index 0f443fc849..ee3aedd210 100644 --- a/lambdas/functions/control-plane/package.json +++ b/lambdas/functions/control-plane/package.json @@ -31,7 +31,6 @@ }, "dependencies": { "@aws-github-runner/aws-powertools-util": "*", - "@aws-github-runner/aws-ssm-util": "*", "@aws-github-runner/compute-providers": "*", "@aws-github-runner/storage-providers": "*", "@aws-lambda-powertools/parameters": "^2.31.0", diff --git a/lambdas/functions/control-plane/src/github/auth.test.ts b/lambdas/functions/control-plane/src/github/auth.test.ts index dd2cf3b8c2..f87053819e 100644 --- a/lambdas/functions/control-plane/src/github/auth.test.ts +++ b/lambdas/functions/control-plane/src/github/auth.test.ts @@ -2,13 +2,19 @@ import { createAppAuth } from '@octokit/auth-app'; import { StrategyOptions } from '@octokit/auth-app/dist-types/types'; import { request } from '@octokit/request'; import { RequestInterface, RequestParameters } from '@octokit/types'; -import { getParameters } from '@aws-github-runner/aws-ssm-util'; +import { + getGitHubAppCredentialsStore, + type GitHubAppCredential, + type GitHubAppCredentialsStore, +} from '@aws-github-runner/storage-providers'; import { generateKeyPairSync } from 'node:crypto'; import * as nock from 'nock'; import { createGithubAppAuth, createOctokitClient, + getAppCount, + getAppId, getStoredInstallationId, onRateLimit, onSecondaryRateLimit, @@ -25,24 +31,27 @@ type MockProxy = T & { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mock = (implementation?: any): MockProxy => vi.fn(implementation) as any; -vi.mock('@aws-github-runner/aws-ssm-util'); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getGitHubAppCredentialsStore: vi.fn(), +})); vi.mock('@octokit/auth-app'); const cleanEnv = process.env; -const ENVIRONMENT = 'dev'; -const GITHUB_APP_ID = '1'; -const PARAMETER_GITHUB_APP_ID_NAME = `/actions-runner/${ENVIRONMENT}/github_app_id`; -const PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`; +const GITHUB_APP_ID = 1; -const mockedGetParameters = vi.mocked(getParameters); +const mockedGetGitHubAppCredentialsStore = vi.mocked(getGitHubAppCredentialsStore); +const mockCredentialsGet = vi.fn(); +const credentialsStore = { + get: mockCredentialsGet, +} satisfies GitHubAppCredentialsStore; beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + mockCredentialsGet.mockReset(); resetAppCredentialsCache(); process.env = { ...cleanEnv }; - process.env.PARAMETER_GITHUB_APP_ID_NAME = PARAMETER_GITHUB_APP_ID_NAME; - process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = PARAMETER_GITHUB_APP_KEY_BASE64_NAME; + mockedGetGitHubAppCredentialsStore.mockReturnValue(credentialsStore); nock.disableNetConnect(); }); @@ -80,38 +89,18 @@ describe('Test createGithubAppAuth', () => { const authType = 'app'; const token = '123456'; const decryptedValue = 'decryptedValue'; - const b64 = Buffer.from(decryptedValue, 'binary').toString('base64'); - - beforeEach(() => { - process.env.ENVIRONMENT = ENVIRONMENT; - }); - it('Throws early when PARAMETER_GITHUB_APP_ID_NAME is not set', async () => { - delete process.env.PARAMETER_GITHUB_APP_ID_NAME; + it('Propagates errors from the credential store', async () => { + const error = new Error('Unable to load GitHub App credentials'); + mockCredentialsGet.mockRejectedValueOnce(error); - await expect(createGithubAppAuth(installationId)).rejects.toThrow( - 'Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set', - ); - expect(mockedGetParameters).not.toHaveBeenCalled(); - }); - - it('Throws early when PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set', async () => { - delete process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME; - - await expect(createGithubAppAuth(installationId)).rejects.toThrow( - 'Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set', - ); - expect(mockedGetParameters).not.toHaveBeenCalled(); + await expect(createGithubAppAuth(installationId)).rejects.toBe(error); + expect(mockCredentialsGet).toHaveBeenCalledOnce(); }); it('Creates auth object with createJwt callback including jti claim', async () => { // Arrange - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - ]), - ); + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]); const mockedAuth = vi.fn(); mockedAuth.mockResolvedValue({ token }); @@ -124,7 +113,7 @@ describe('Test createGithubAppAuth', () => { // Assert expect(mockedCreatAppAuth).toBeCalledTimes(1); const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record; - expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID)); + expect(callArgs.appId).toBe(GITHUB_APP_ID); expect(callArgs.createJwt).toBeTypeOf('function'); expect(callArgs).not.toHaveProperty('privateKey'); expect(callArgs.installationId).toBe(installationId); @@ -137,14 +126,7 @@ describe('Test createGithubAppAuth', () => { privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' }, }); - const b64Key = Buffer.from(privateKey as string).toString('base64'); - - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64Key], - ]), - ); + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: privateKey as string }]); let capturedCreateJwt: (appId: string | number, timeDifference?: number) => Promise<{ jwt: string }>; mockedCreatAppAuth.mockImplementation((opts: StrategyOptions) => { @@ -173,41 +155,9 @@ describe('Test createGithubAppAuth', () => { expect(payload).toHaveProperty('iss'); }); - it('Creates auth object with line breaks in SSH key.', async () => { - // Arrange - const b64PrivateKeyWithLineBreaks = Buffer.from(decryptedValue + '\n' + decryptedValue, 'binary').toString( - 'base64', - ); - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64PrivateKeyWithLineBreaks], - ]), - ); - - const mockedAuth = vi.fn(); - mockedAuth.mockResolvedValue({ token }); - const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() }); - mockedCreatAppAuth.mockReturnValue(mockWithHook); - - // Act - const result = await createGithubAppAuth(installationId); - - // Assert - expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]); - expect(mockedCreatAppAuth).toBeCalledTimes(1); - expect(mockedAuth).toBeCalledWith({ type: authType }); - expect(result.token).toBe(token); - }); - it('Creates auth object for public GitHub', async () => { // Arrange - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - ]), - ); + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]); const mockedAuth = vi.fn(); mockedAuth.mockResolvedValue({ token }); @@ -218,11 +168,9 @@ describe('Test createGithubAppAuth', () => { const result = await createGithubAppAuth(installationId); // Assert - expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]); - expect(mockedCreatAppAuth).toBeCalledTimes(1); const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record; - expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID)); + expect(callArgs.appId).toBe(GITHUB_APP_ID); expect(callArgs.createJwt).toBeTypeOf('function'); expect(callArgs.installationId).toBe(installationId); expect(mockedAuth).toBeCalledWith({ type: authType }); @@ -238,12 +186,7 @@ describe('Test createGithubAppAuth', () => { () => mockedRequestInterface as RequestInterface, ); - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - ]), - ); + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]); const mockedAuth = vi.fn(); mockedAuth.mockResolvedValue({ token }); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -255,11 +198,9 @@ describe('Test createGithubAppAuth', () => { const result = await createGithubAppAuth(installationId, githubServerUrl); // Assert - expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]); - expect(mockedCreatAppAuth).toBeCalledTimes(1); const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record; - expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID)); + expect(callArgs.appId).toBe(GITHUB_APP_ID); expect(callArgs.createJwt).toBeTypeOf('function'); expect(callArgs.installationId).toBe(installationId); expect(callArgs.request).toBeDefined(); @@ -278,12 +219,7 @@ describe('Test createGithubAppAuth', () => { const installationId = undefined; - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - ]), - ); + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]); const mockedAuth = vi.fn(); mockedAuth.mockResolvedValue({ token }); const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() }); @@ -293,11 +229,9 @@ describe('Test createGithubAppAuth', () => { const result = await createGithubAppAuth(installationId, githubServerUrl); // Assert - expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]); - expect(mockedCreatAppAuth).toBeCalledTimes(1); const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record; - expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID)); + expect(callArgs.appId).toBe(GITHUB_APP_ID); expect(callArgs.createJwt).toBeTypeOf('function'); expect(callArgs).not.toHaveProperty('installationId'); expect(callArgs.request).toBeDefined(); @@ -330,98 +264,48 @@ describe('Test throttling retry caps', () => { }); }); -describe('Test getStoredInstallationId', () => { - const decryptedValue = 'decryptedValue'; - const b64 = Buffer.from(decryptedValue, 'binary').toString('base64'); - - beforeEach(() => { - const mockedAuth = vi.fn(); - mockedAuth.mockResolvedValue({ token: 'token' }); - const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() }); - vi.mocked(createAppAuth).mockReturnValue(mockWithHook); - }); - +describe('Test GitHub App credential accessors', () => { it('returns stored installation ID when configured', async () => { - const installationIdParam = `/actions-runner/${ENVIRONMENT}/github_app_installation_id`; - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParam; - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - [installationIdParam, '12345'], - ]), - ); + mockCredentialsGet.mockResolvedValueOnce([ + { appId: GITHUB_APP_ID, privateKey: 'private-key', installationId: 12345 }, + ]); const result = await getStoredInstallationId(0); expect(result).toBe(12345); }); - it('returns undefined when installation ID param is empty', async () => { - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = ''; - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - ]), - ); - - const result = await getStoredInstallationId(0); - expect(result).toBeUndefined(); - }); - - it('returns undefined when env var is not set', async () => { - delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME; - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - ]), - ); + it('returns undefined when the credential has no installation ID', async () => { + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]); const result = await getStoredInstallationId(0); expect(result).toBeUndefined(); }); it('returns undefined for out-of-bounds appIndex', async () => { - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = ''; - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], - [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], - ]), - ); + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]); const result = await getStoredInstallationId(99); expect(result).toBeUndefined(); }); - it('loads installation IDs for multi-app setup', async () => { - const app1IdParam = `/actions-runner/${ENVIRONMENT}/github_app_id`; - const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`; - const app1KeyParam = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`; - const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`; - const app2InstallParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`; - - process.env.PARAMETER_GITHUB_APP_ID_NAME = `${app1IdParam}:${app2IdParam}`; - process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${app1KeyParam}:${app2KeyParam}`; - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${app2InstallParam}`; - - mockedGetParameters.mockResolvedValueOnce( - new Map([ - [app1IdParam, '1'], - [app1KeyParam, b64], - [app2IdParam, '2'], - [app2KeyParam, b64], - [app2InstallParam, '67890'], - ]), - ); + it('loads multi-app credentials once and exposes values by index', async () => { + const credentials: GitHubAppCredential[] = [ + { appId: 1, privateKey: 'private-key-1' }, + { appId: 2, privateKey: 'private-key-2', installationId: 67890 }, + ]; + mockCredentialsGet.mockResolvedValueOnce(credentials); + + await expect(getAppCount()).resolves.toBe(2); + await expect(getAppId()).resolves.toBe('1'); + await expect(getAppId(1)).resolves.toBe('2'); + await expect(getStoredInstallationId(0)).resolves.toBeUndefined(); + await expect(getStoredInstallationId(1)).resolves.toBe(67890); + expect(mockCredentialsGet).toHaveBeenCalledOnce(); + }); - // Primary app (index 0) has no stored installation ID - const result0 = await getStoredInstallationId(0); - expect(result0).toBeUndefined(); + it('throws a clear error for an out-of-bounds app ID index', async () => { + mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]); - // Additional app (index 1) has stored installation ID - const result1 = await getStoredInstallationId(1); - expect(result1).toBe(67890); + await expect(getAppId(99)).rejects.toThrow('GitHub App credential at index 99 not found'); }); }); diff --git a/lambdas/functions/control-plane/src/github/auth.ts b/lambdas/functions/control-plane/src/github/auth.ts index f64ac00b30..e4ade0b38a 100644 --- a/lambdas/functions/control-plane/src/github/auth.ts +++ b/lambdas/functions/control-plane/src/github/auth.ts @@ -22,7 +22,7 @@ import { Octokit } from '@octokit/rest'; import { retry } from '@octokit/plugin-retry'; import { throttling } from '@octokit/plugin-throttling'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { getParameters } from '@aws-github-runner/aws-ssm-util'; +import { getGitHubAppCredentialsStore, type GitHubAppCredential } from '@aws-github-runner/storage-providers'; import { EndpointDefaults } from '@octokit/types'; const logger = createChildLogger('gh-auth'); @@ -69,52 +69,10 @@ export function onSecondaryRateLimit( return retryCount < MAX_SECONDARY_RATE_LIMIT_RETRIES; } -interface GitHubAppCredential { - appId: number; - privateKey: string; - installationId?: number; -} - let appCredentialsPromise: Promise | null = null; async function loadAppCredentials(): Promise { - if (!process.env.PARAMETER_GITHUB_APP_ID_NAME) { - throw new Error('Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set'); - } - if (!process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME) { - throw new Error('Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set'); - } - const idParams = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':').filter(Boolean); - const keyParams = process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME.split(':').filter(Boolean); - const installationIdParams = (process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME || '').split(':'); - if (idParams.length !== keyParams.length) { - throw new Error(`GitHub App parameter count mismatch: ${idParams.length} IDs vs ${keyParams.length} keys`); - } - // Batch fetch all SSM parameters in a single call to reduce API calls - const allParamNames = [...idParams, ...keyParams, ...installationIdParams.filter((p) => p.length > 0)]; - const params = await getParameters(allParamNames); - - const credentials: GitHubAppCredential[] = []; - for (let i = 0; i < idParams.length; i++) { - const appIdValue = params.get(idParams[i]); - if (!appIdValue) { - throw new Error(`Parameter ${idParams[i]} not found`); - } - const appId = parseInt(appIdValue, 10); - const privateKeyBase64 = params.get(keyParams[i]); - if (!privateKeyBase64) { - throw new Error(`Parameter ${keyParams[i]} not found`); - } - // replace literal \n characters with new lines to allow the key to be stored as a - // single line variable. This logic should match how the GitHub Terraform provider - // processes private keys to retain compatibility between the projects - const privateKey = Buffer.from(privateKeyBase64, 'base64').toString().replace(/\\n/g, '\n'); - const installationIdParam = installationIdParams[i]; - const installationIdValue = - installationIdParam && installationIdParam.length > 0 ? params.get(installationIdParam) : undefined; - const installationId = installationIdValue ? parseInt(installationIdValue, 10) : undefined; - credentials.push({ appId, privateKey, installationId }); - } + const credentials = await getGitHubAppCredentialsStore().get(); logger.info(`Loaded ${credentials.length} GitHub App credential(s)`); return credentials; } @@ -137,6 +95,14 @@ export async function getStoredInstallationId(appIndex: number): Promise { + const credential = (await getAppCredentials())[appIndex]; + if (!credential) { + throw new Error(`GitHub App credential at index ${appIndex} not found`); + } + return credential.appId.toString(); +} + export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise { const CustomOctokit = Octokit.plugin(retry, throttling); const ocktokitOptions: OctokitOptions = { diff --git a/lambdas/functions/control-plane/src/github/rate-limit.test.ts b/lambdas/functions/control-plane/src/github/rate-limit.test.ts index d9d18c5921..93e6d24ba0 100644 --- a/lambdas/functions/control-plane/src/github/rate-limit.test.ts +++ b/lambdas/functions/control-plane/src/github/rate-limit.test.ts @@ -1,50 +1,37 @@ -import { ResponseHeaders } from '@octokit/types'; -import { createSingleMetric } from '@aws-github-runner/aws-powertools-util'; import { MetricUnit } from '@aws-lambda-powertools/metrics'; +import { createSingleMetric } from '@aws-github-runner/aws-powertools-util'; +import type { ResponseHeaders } from '@octokit/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getAppId } from './auth'; import { metricGitHubAppRateLimit } from './rate-limit'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { getParameter } from '@aws-github-runner/aws-ssm-util'; - -process.env.PARAMETER_GITHUB_APP_ID_NAME = 'test'; -vi.mock('@aws-github-runner/aws-ssm-util', async () => { - // Return only what we need without spreading actual - return { - getParameter: vi.fn((name: string) => { - if (name === process.env.PARAMETER_GITHUB_APP_ID_NAME) { - return '1234'; - } else { - return ''; - } - }), - }; -}); -vi.mock('@aws-github-runner/aws-powertools-util', async () => { - // Provide only what's needed without spreading actual - return { - // Mock the logger - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - createSingleMetric: vi.fn((name: string, unit: string, value: number, dimensions?: Record) => { - return { - addMetadata: vi.fn(), - }; - }), - }; +vi.mock('./auth', () => ({ + getAppId: vi.fn(), +})); + +vi.mock('@aws-github-runner/aws-powertools-util', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + createSingleMetric: vi.fn(() => ({ addMetadata: vi.fn() })), +})); + +const cleanEnv = process.env; +const mockedGetAppId = vi.mocked(getAppId); + +beforeEach(() => { + vi.clearAllMocks(); + mockedGetAppId.mockReset(); + mockedGetAppId.mockResolvedValue('1234'); + process.env = { ...cleanEnv }; }); describe('metricGitHubAppRateLimit', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should update rate limit metric', async () => { - // set process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT to true + it('updates the rate limit metric', async () => { process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = 'true'; const headers: ResponseHeaders = { 'x-ratelimit-remaining': '10', @@ -53,13 +40,13 @@ describe('metricGitHubAppRateLimit', () => { await metricGitHubAppRateLimit(headers); + expect(mockedGetAppId).toHaveBeenCalledWith(undefined); expect(createSingleMetric).toHaveBeenCalledWith('GitHubAppRateLimitRemaining', MetricUnit.Count, 10, { AppId: '1234', }); }); - it('should not update rate limit metric', async () => { - // set process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT to false + it('does not update the rate limit metric when disabled', async () => { process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = 'false'; const headers: ResponseHeaders = { 'x-ratelimit-remaining': '10', @@ -68,107 +55,85 @@ describe('metricGitHubAppRateLimit', () => { await metricGitHubAppRateLimit(headers); + expect(mockedGetAppId).not.toHaveBeenCalled(); expect(createSingleMetric).not.toHaveBeenCalled(); }); - it('should not update rate limit metric if headers are undefined', async () => { - // set process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT to true + it('does not update the rate limit metric if headers are undefined', async () => { process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = 'true'; await metricGitHubAppRateLimit(undefined as unknown as ResponseHeaders); + expect(mockedGetAppId).not.toHaveBeenCalled(); expect(createSingleMetric).not.toHaveBeenCalled(); }); - it('should cache GitHub App ID and only call getParameter once', async () => { - // Reset modules to clear the appIdPromises Map cache - vi.resetModules(); - const { metricGitHubAppRateLimit: freshMetricFunction } = await import('./rate-limit'); - + it('does not update the metric when the app ID lookup fails', async () => { process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = 'true'; + mockedGetAppId.mockRejectedValueOnce(new Error('credential store unavailable')); const headers: ResponseHeaders = { 'x-ratelimit-remaining': '10', 'x-ratelimit-limit': '60', }; - const mockGetParameter = vi.mocked(getParameter); - mockGetParameter.mockClear(); + await expect(metricGitHubAppRateLimit(headers)).resolves.not.toThrow(); - await freshMetricFunction(headers); - await freshMetricFunction(headers); - await freshMetricFunction(headers); - - // getParameter should only be called once due to caching (index 0 cached after first call) - expect(mockGetParameter).toHaveBeenCalledTimes(1); - // split(':')[0] of 'test' is still 'test' - expect(mockGetParameter).toHaveBeenCalledWith(process.env.PARAMETER_GITHUB_APP_ID_NAME); + expect(createSingleMetric).not.toHaveBeenCalled(); }); }); describe('metricGitHubAppRateLimit multi-app', () => { - let freshMetricFunction: typeof metricGitHubAppRateLimit; - let mockGetParam: ReturnType; - - beforeEach(async () => { - // Reset modules to get a clean appIdPromises Map for each test - vi.resetModules(); - - process.env.PARAMETER_GITHUB_APP_ID_NAME = 'app0:app1'; + beforeEach(() => { process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = 'true'; - - mockGetParam = vi.fn((name: string) => { - if (name === 'app0') return Promise.resolve('1234'); - if (name === 'app1') return Promise.resolve('5678'); - return Promise.resolve(''); + mockedGetAppId.mockImplementation(async (appIndex = 0) => { + if (appIndex === 0) return '1234'; + if (appIndex === 1) return '5678'; + throw new Error(`GitHub App credential at index ${appIndex} not found`); }); - - vi.doMock('@aws-github-runner/aws-ssm-util', () => ({ getParameter: mockGetParam })); - vi.doMock('@aws-github-runner/aws-powertools-util', () => ({ - logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - createSingleMetric: vi.fn(() => ({ addMetadata: vi.fn() })), - })); - - const mod = await import('./rate-limit'); - freshMetricFunction = mod.metricGitHubAppRateLimit; - }); - - afterEach(() => { - vi.resetModules(); - process.env.PARAMETER_GITHUB_APP_ID_NAME = 'test'; }); - it('should label metric with correct appId for index 0 (primary app)', async () => { - const { createSingleMetric: mockMetric } = await import('@aws-github-runner/aws-powertools-util'); + it('labels the metric with the primary app ID', async () => { const headers: ResponseHeaders = { 'x-ratelimit-remaining': '50', 'x-ratelimit-limit': '5000' }; - await freshMetricFunction(headers, 0); - expect(mockMetric).toHaveBeenCalledWith('GitHubAppRateLimitRemaining', MetricUnit.Count, 50, { AppId: '1234' }); + + await metricGitHubAppRateLimit(headers, 0); + + expect(mockedGetAppId).toHaveBeenCalledWith(0); + expect(createSingleMetric).toHaveBeenCalledWith('GitHubAppRateLimitRemaining', MetricUnit.Count, 50, { + AppId: '1234', + }); }); - it('should label metric with correct appId for index 1 (additional app)', async () => { - const { createSingleMetric: mockMetric } = await import('@aws-github-runner/aws-powertools-util'); + it('labels the metric with an additional app ID', async () => { const headers: ResponseHeaders = { 'x-ratelimit-remaining': '100', 'x-ratelimit-limit': '5000' }; - await freshMetricFunction(headers, 1); - expect(mockMetric).toHaveBeenCalledWith('GitHubAppRateLimitRemaining', MetricUnit.Count, 100, { AppId: '5678' }); + + await metricGitHubAppRateLimit(headers, 1); + + expect(mockedGetAppId).toHaveBeenCalledWith(1); + expect(createSingleMetric).toHaveBeenCalledWith('GitHubAppRateLimitRemaining', MetricUnit.Count, 100, { + AppId: '5678', + }); }); - it('should default to index 0 when no appIndex is passed', async () => { - const { createSingleMetric: mockMetric } = await import('@aws-github-runner/aws-powertools-util'); + it('defaults to the primary app when no app index is passed', async () => { const headers: ResponseHeaders = { 'x-ratelimit-remaining': '75', 'x-ratelimit-limit': '5000' }; - await freshMetricFunction(headers); - expect(mockMetric).toHaveBeenCalledWith('GitHubAppRateLimitRemaining', MetricUnit.Count, 75, { AppId: '1234' }); + + await metricGitHubAppRateLimit(headers); + + expect(mockedGetAppId).toHaveBeenCalledWith(undefined); + expect(createSingleMetric).toHaveBeenCalledWith('GitHubAppRateLimitRemaining', MetricUnit.Count, 75, { + AppId: '1234', + }); }); - it('should cache per index and call getParameter separately for each index', async () => { + it('forwards each app index to the shared credential accessor', async () => { const headers: ResponseHeaders = { 'x-ratelimit-remaining': '10', 'x-ratelimit-limit': '5000' }; - // Two calls with index 1, then one with index 0 - await freshMetricFunction(headers, 1); - await freshMetricFunction(headers, 1); - await freshMetricFunction(headers, 0); + await metricGitHubAppRateLimit(headers, 1); + await metricGitHubAppRateLimit(headers, 1); + await metricGitHubAppRateLimit(headers, 0); - // getParameter should be called exactly once per distinct index - expect(mockGetParam).toHaveBeenCalledTimes(2); - expect(mockGetParam).toHaveBeenCalledWith('app1'); - expect(mockGetParam).toHaveBeenCalledWith('app0'); + expect(mockedGetAppId).toHaveBeenNthCalledWith(1, 1); + expect(mockedGetAppId).toHaveBeenNthCalledWith(2, 1); + expect(mockedGetAppId).toHaveBeenNthCalledWith(3, 0); }); }); diff --git a/lambdas/functions/control-plane/src/github/rate-limit.ts b/lambdas/functions/control-plane/src/github/rate-limit.ts index df2372a255..b5559a5d82 100644 --- a/lambdas/functions/control-plane/src/github/rate-limit.ts +++ b/lambdas/functions/control-plane/src/github/rate-limit.ts @@ -2,22 +2,8 @@ import { ResponseHeaders } from '@octokit/types'; import { createSingleMetric, logger } from '@aws-github-runner/aws-powertools-util'; import { MetricUnit } from '@aws-lambda-powertools/metrics'; import yn from 'yn'; -import { getParameter } from '@aws-github-runner/aws-ssm-util'; -// Cache the app ID per app index to avoid repeated SSM calls across Lambda invocations. -// In multi-app mode PARAMETER_GITHUB_APP_ID_NAME is a ':'-joined list of SSM param names, -// one per app in app-index order; index 0 is the primary app. -const appIdPromises = new Map>(); - -async function getAppId(appIndex = 0): Promise { - let cached = appIdPromises.get(appIndex); - if (!cached) { - const paramName = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':')[appIndex]; - cached = getParameter(paramName); - appIdPromises.set(appIndex, cached); - } - return cached; -} +import { getAppId } from './auth'; export async function metricGitHubAppRateLimit(headers: ResponseHeaders, appIndex?: number): Promise { try { diff --git a/lambdas/functions/control-plane/src/lambda.test.ts b/lambdas/functions/control-plane/src/lambda.test.ts index f93b4eac49..4c61f2c585 100644 --- a/lambdas/functions/control-plane/src/lambda.test.ts +++ b/lambdas/functions/control-plane/src/lambda.test.ts @@ -66,7 +66,6 @@ vi.mock('./scale-runners/scale-down'); vi.mock('./scale-runners/scale-up'); vi.mock('./scale-runners/job-retry'); vi.mock('@aws-github-runner/aws-powertools-util'); -vi.mock('@aws-github-runner/aws-ssm-util'); vi.mock('@aws-github-runner/storage-providers', () => ({ getRunnerConfigStore: vi.fn(), })); diff --git a/lambdas/functions/control-plane/src/modules.d.ts b/lambdas/functions/control-plane/src/modules.d.ts index d32f8431e0..af537afcba 100644 --- a/lambdas/functions/control-plane/src/modules.d.ts +++ b/lambdas/functions/control-plane/src/modules.d.ts @@ -13,8 +13,6 @@ declare namespace NodeJS { MINIMUM_RUNNING_TIME_IN_MINUTES: string; PARAMETER_GITHUB_APP_CLIENT_ID_NAME: string; PARAMETER_GITHUB_APP_CLIENT_SECRET_NAME: string; - PARAMETER_GITHUB_APP_ID_NAME: string; - PARAMETER_GITHUB_APP_KEY_BASE64_NAME: string; RUNNER_OWNER: string; COMPUTE_PROVIDER_TYPE?: string; SCALE_DOWN_CONFIG: string; diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 4e90321f30..4ab47c0bba 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -147,7 +147,6 @@ let expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; function setDefaults() { process.env = { ...cleanEnv }; - process.env.PARAMETER_GITHUB_APP_ID_NAME = 'github-app-id'; process.env.GITHUB_APP_KEY_BASE64 = 'TEST_CERTIFICATE_DATA'; process.env.GITHUB_APP_ID = '1337'; process.env.GITHUB_APP_CLIENT_ID = 'TEST_CLIENT_ID'; diff --git a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts index b3fb63cdbe..ba0e7afda0 100644 --- a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts +++ b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts @@ -3,6 +3,9 @@ export {}; declare global { namespace NodeJS { interface ProcessEnv { + PARAMETER_GITHUB_APP_ID_NAME?: string; + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME?: string; + PARAMETER_GITHUB_APP_KEY_BASE64_NAME?: string; SSM_CONFIG_PATH?: string; SSM_CLEANUP_CONFIG?: string; SSM_PARAMETER_STORE_TAGS?: string; diff --git a/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts new file mode 100644 index 0000000000..4ca2e93914 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts @@ -0,0 +1,154 @@ +import { getParameters } from '@aws-github-runner/aws-ssm-util'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmGitHubAppCredentialsStore } from './github-app-credentials-store'; + +vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + getParameters: vi.fn(), +})); + +const getParametersMock = vi.mocked(getParameters); +const cleanEnv = process.env; +const primaryIdParameter = '/actions-runner/test/github_app_id'; +const primaryKeyParameter = '/actions-runner/test/github_app_key_base64'; + +describe('aws_ssm GitHub App credentials store', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + process.env.PARAMETER_GITHUB_APP_ID_NAME = primaryIdParameter; + process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = primaryKeyParameter; + delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME; + }); + + it('batch reads and maps the primary GitHub App credential', async () => { + const privateKey = 'fake-private-key'; + getParametersMock.mockResolvedValue( + new Map([ + [primaryIdParameter, '123'], + [primaryKeyParameter, Buffer.from(privateKey).toString('base64')], + ]), + ); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).resolves.toEqual([{ appId: 123, privateKey, installationId: undefined }]); + expect(getParametersMock).toHaveBeenCalledOnce(); + expect(getParametersMock).toHaveBeenCalledWith([primaryIdParameter, primaryKeyParameter]); + }); + + it('preserves multi-app order and optional installation-id slots', async () => { + const additionalIdParameter = '/actions-runner/test/additional_github_app_0_id'; + const additionalKeyParameter = '/actions-runner/test/additional_github_app_0_key_base64'; + const additionalInstallationIdParameter = '/actions-runner/test/additional_github_app_0_installation_id'; + process.env.PARAMETER_GITHUB_APP_ID_NAME = `${primaryIdParameter}:${additionalIdParameter}`; + process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${primaryKeyParameter}:${additionalKeyParameter}`; + process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${additionalInstallationIdParameter}`; + getParametersMock.mockResolvedValue( + new Map([ + [primaryIdParameter, '123'], + [primaryKeyParameter, Buffer.from('primary-key').toString('base64')], + [additionalIdParameter, '456'], + [additionalKeyParameter, Buffer.from('additional-key').toString('base64')], + [additionalInstallationIdParameter, '789'], + ]), + ); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).resolves.toEqual([ + { appId: 123, privateKey: 'primary-key', installationId: undefined }, + { appId: 456, privateKey: 'additional-key', installationId: 789 }, + ]); + expect(getParametersMock).toHaveBeenCalledWith([ + primaryIdParameter, + additionalIdParameter, + primaryKeyParameter, + additionalKeyParameter, + additionalInstallationIdParameter, + ]); + }); + + it('decodes literal newline escapes in a base64 private key', async () => { + getParametersMock.mockResolvedValue( + new Map([ + [primaryIdParameter, '123'], + [primaryKeyParameter, Buffer.from('first-line\\nsecond-line').toString('base64')], + ]), + ); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).resolves.toEqual([ + { appId: 123, privateKey: 'first-line\nsecond-line', installationId: undefined }, + ]); + }); + + it('preserves parseInt behavior for stored numeric values', async () => { + const installationIdParameter = '/actions-runner/test/github_app_installation_id'; + process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParameter; + getParametersMock.mockResolvedValue( + new Map([ + [primaryIdParameter, '123app'], + [primaryKeyParameter, Buffer.from('fake-private-key').toString('base64')], + [installationIdParameter, '789installation'], + ]), + ); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).resolves.toEqual([{ appId: 123, privateKey: 'fake-private-key', installationId: 789 }]); + }); + + it.each([ + ['PARAMETER_GITHUB_APP_ID_NAME', undefined], + ['PARAMETER_GITHUB_APP_ID_NAME', ''], + ['PARAMETER_GITHUB_APP_KEY_BASE64_NAME', undefined], + ['PARAMETER_GITHUB_APP_KEY_BASE64_NAME', ''], + ] as const)('rejects missing environment value %s=%j before reading', async (name, value) => { + setEnvironmentValue(name, value); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).rejects.toThrow(`Environment variable ${name} is not set`); + expect(getParametersMock).not.toHaveBeenCalled(); + }); + + it('rejects mismatched GitHub App id and key parameter counts before reading', async () => { + process.env.PARAMETER_GITHUB_APP_ID_NAME = `${primaryIdParameter}:/additional/id`; + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).rejects.toThrow('GitHub App parameter count mismatch: 2 IDs vs 1 keys'); + expect(getParametersMock).not.toHaveBeenCalled(); + }); + + it('rejects a missing GitHub App id parameter', async () => { + getParametersMock.mockResolvedValue( + new Map([[primaryKeyParameter, Buffer.from('fake-private-key').toString('base64')]]), + ); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).rejects.toThrow(`Parameter ${primaryIdParameter} not found`); + }); + + it('rejects a missing GitHub App private-key parameter', async () => { + getParametersMock.mockResolvedValue(new Map([[primaryIdParameter, '123']])); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).rejects.toThrow(`Parameter ${primaryKeyParameter} not found`); + }); + + it('propagates parameter-store read errors', async () => { + const error = new Error('access denied'); + getParametersMock.mockRejectedValue(error); + const store = createAwsSsmGitHubAppCredentialsStore(); + + await expect(store.get()).rejects.toBe(error); + }); +}); + +function setEnvironmentValue( + name: 'PARAMETER_GITHUB_APP_ID_NAME' | 'PARAMETER_GITHUB_APP_KEY_BASE64_NAME', + value: string | undefined, +): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts new file mode 100644 index 0000000000..5e5ca2e501 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts @@ -0,0 +1,61 @@ +import { getParameters } from '@aws-github-runner/aws-ssm-util'; + +import type { GitHubAppCredential, GitHubAppCredentialsStore } from '../../core'; +import type {} from './environment'; + +export function createAwsSsmGitHubAppCredentialsStore(): GitHubAppCredentialsStore { + return new AwsSsmGitHubAppCredentialsStore(); +} + +class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore { + async get(): Promise { + if (!process.env.PARAMETER_GITHUB_APP_ID_NAME) { + throw new Error('Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set'); + } + if (!process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME) { + throw new Error('Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set'); + } + + const idParameters = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':').filter(Boolean); + const keyParameters = process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME.split(':').filter(Boolean); + const installationIdParameters = (process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME || '').split(':'); + if (idParameters.length !== keyParameters.length) { + throw new Error( + `GitHub App parameter count mismatch: ${idParameters.length} IDs vs ${keyParameters.length} keys`, + ); + } + + const parameterNames = [ + ...idParameters, + ...keyParameters, + ...installationIdParameters.filter((parameter) => parameter.length > 0), + ]; + const parameters = await getParameters(parameterNames); + + const credentials: GitHubAppCredential[] = []; + for (let index = 0; index < idParameters.length; index++) { + const appIdValue = parameters.get(idParameters[index]); + if (!appIdValue) { + throw new Error(`Parameter ${idParameters[index]} not found`); + } + + const privateKeyBase64 = parameters.get(keyParameters[index]); + if (!privateKeyBase64) { + throw new Error(`Parameter ${keyParameters[index]} not found`); + } + + const installationIdParameter = installationIdParameters[index]; + const installationIdValue = installationIdParameter ? parameters.get(installationIdParameter) : undefined; + + credentials.push({ + appId: parseInt(appIdValue, 10), + // Match the GitHub Terraform provider's handling of keys stored as a + // single-line base64 value containing literal newline escapes. + privateKey: Buffer.from(privateKeyBase64, 'base64').toString().replace(/\\n/g, '\n'), + installationId: installationIdValue ? parseInt(installationIdValue, 10) : undefined, + }); + } + + return credentials; + } +} diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts index 6fba06f035..a044489348 100644 --- a/lambdas/libs/storage-providers/core/index.ts +++ b/lambdas/libs/storage-providers/core/index.ts @@ -1,3 +1,13 @@ +export interface GitHubAppCredential { + appId: number; + privateKey: string; + installationId?: number; +} + +export interface GitHubAppCredentialsStore { + get(): Promise; +} + export interface RunnerConfigMetadata { key: string; value: string; diff --git a/lambdas/libs/storage-providers/github-app-credentials.test.ts b/lambdas/libs/storage-providers/github-app-credentials.test.ts new file mode 100644 index 0000000000..fe1870e387 --- /dev/null +++ b/lambdas/libs/storage-providers/github-app-credentials.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; +import type { GitHubAppCredentialsStore } from './core'; +import { getGitHubAppCredentialsStore, resetGitHubAppCredentialsStore } from './github-app-credentials'; + +vi.mock('./aws/ssm/github-app-credentials-store', () => ({ + createAwsSsmGitHubAppCredentialsStore: vi.fn(), +})); + +const createAwsSsmGitHubAppCredentialsStoreMock = vi.mocked(createAwsSsmGitHubAppCredentialsStore); +const cleanEnv = process.env; + +describe('GitHub App credentials store selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + resetGitHubAppCredentialsStore(); + }); + + it.each([undefined, '', ' '])('uses aws_ssm for default selector input %j', (provider) => { + setProvider(provider); + const store = stubStore(); + + expect(getGitHubAppCredentialsStore()).toBe(store); + expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); + }); + + it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubStore(); + + expect(getGitHubAppCredentialsStore()).toBe(store); + expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); + }); + + it('rejects an unsupported provider on first use', () => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + + expect(() => getGitHubAppCredentialsStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsSsmGitHubAppCredentialsStoreMock).not.toHaveBeenCalled(); + }); + + it('selects lazily and caches the created store', () => { + const store = stubStore(); + + expect(createAwsSsmGitHubAppCredentialsStoreMock).not.toHaveBeenCalled(); + const first = getGitHubAppCredentialsStore(); + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + const second = getGitHubAppCredentialsStore(); + + expect(first).toBe(store); + expect(second).toBe(store); + expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); + }); + + it('selects again after the test reset', () => { + const firstStore = stubStore(); + expect(getGitHubAppCredentialsStore()).toBe(firstStore); + + const secondStore = { get: vi.fn() } satisfies GitHubAppCredentialsStore; + createAwsSsmGitHubAppCredentialsStoreMock.mockReturnValue(secondStore); + resetGitHubAppCredentialsStore(); + + expect(getGitHubAppCredentialsStore()).toBe(secondStore); + expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledTimes(2); + }); +}); + +function setProvider(provider: string | undefined): void { + if (provider === undefined) { + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + } else { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + } +} + +function stubStore(): GitHubAppCredentialsStore { + const store = { get: vi.fn() } satisfies GitHubAppCredentialsStore; + createAwsSsmGitHubAppCredentialsStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/github-app-credentials.ts b/lambdas/libs/storage-providers/github-app-credentials.ts new file mode 100644 index 0000000000..683ab6bb3e --- /dev/null +++ b/lambdas/libs/storage-providers/github-app-credentials.ts @@ -0,0 +1,23 @@ +import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; +import type { GitHubAppCredentialsStore } from './core'; +import type {} from './environment'; +import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; + +type GitHubAppCredentialsStoreFactory = () => GitHubAppCredentialsStore; + +const providerFactories = { + aws_ssm: createAwsSsmGitHubAppCredentialsStore, +} as const satisfies Record; + +let githubAppCredentialsStore: GitHubAppCredentialsStore | undefined; + +export function getGitHubAppCredentialsStore(): GitHubAppCredentialsStore { + githubAppCredentialsStore ??= + providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + return githubAppCredentialsStore; +} + +// Test-only reset for cases that need to exercise first-use environment selection. +export function resetGitHubAppCredentialsStore(): void { + githubAppCredentialsStore = undefined; +} diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index 8001457df5..05a1d5e285 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -1,9 +1,12 @@ export type { + GitHubAppCredential, + GitHubAppCredentialsStore, RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore, RunnerGroupCacheRecord, RunnerGroupCacheStore, } from './core'; +export { getGitHubAppCredentialsStore, resetGitHubAppCredentialsStore } from './github-app-credentials'; export { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; export { getRunnerGroupCacheStore, resetRunnerGroupCacheStore } from './runner-group-cache'; diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts index af85b8946d..d43a3721eb 100644 --- a/lambdas/libs/storage-providers/vitest.config.ts +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -7,7 +7,15 @@ export default mergeConfig(defaultConfig, { test: { setupFiles: [resolve(__dirname, '../../aws-vitest-setup.ts')], coverage: { - include: ['index.ts', 'provider.ts', 'runner-config.ts', 'runner-group-cache.ts', 'core/**/*.ts', 'aws/**/*.ts'], + include: [ + 'index.ts', + 'provider.ts', + 'github-app-credentials.ts', + 'runner-config.ts', + 'runner-group-cache.ts', + 'core/**/*.ts', + 'aws/**/*.ts', + ], exclude: ['**/*.test.ts', '**/*.d.ts'], }, }, diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 09a7c33df5..b7eaf7b3b4 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -162,7 +162,6 @@ __metadata: resolution: "@aws-github-runner/control-plane@workspace:functions/control-plane" dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" - "@aws-github-runner/aws-ssm-util": "npm:*" "@aws-github-runner/compute-providers": "npm:*" "@aws-github-runner/storage-providers": "npm:*" "@aws-lambda-powertools/parameters": "npm:^2.31.0" From 54403daf0f9159cff1b52172bc77d713b37e5e79 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 11:02:53 +0200 Subject: [PATCH 43/49] refactor(storage): extract webhook matcher config --- lambdas/functions/webhook/package.json | 1 + .../webhook/src/ConfigLoader.test.ts | 163 +++++------------- lambdas/functions/webhook/src/ConfigLoader.ts | 42 +---- lambdas/functions/webhook/src/lambda.test.ts | 15 +- lambdas/functions/webhook/src/modules.d.ts | 1 - .../webhook/src/runners/dispatch.test.ts | 26 ++- .../webhook/src/webhook/index.test.ts | 22 ++- .../aws/ssm/environment.d.ts | 1 + .../ssm/runner-matcher-config-store.test.ts | 103 +++++++++++ .../aws/ssm/runner-matcher-config-store.ts | 69 ++++++++ lambdas/libs/storage-providers/core/index.ts | 4 + lambdas/libs/storage-providers/index.ts | 2 + .../runner-matcher-config.test.ts | 83 +++++++++ .../runner-matcher-config.ts | 23 +++ .../libs/storage-providers/vitest.config.ts | 1 + lambdas/yarn.lock | 1 + 16 files changed, 369 insertions(+), 188 deletions(-) create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.ts create mode 100644 lambdas/libs/storage-providers/runner-matcher-config.test.ts create mode 100644 lambdas/libs/storage-providers/runner-matcher-config.ts diff --git a/lambdas/functions/webhook/package.json b/lambdas/functions/webhook/package.json index 34f4ef3de9..83d9810d1f 100644 --- a/lambdas/functions/webhook/package.json +++ b/lambdas/functions/webhook/package.json @@ -31,6 +31,7 @@ "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", "@aws-github-runner/compute-providers": "*", + "@aws-github-runner/storage-providers": "*", "@aws-sdk/client-sqs": "^3.1009.0", "@middy/core": "^6.4.5", "@octokit/rest": "22.0.1", diff --git a/lambdas/functions/webhook/src/ConfigLoader.test.ts b/lambdas/functions/webhook/src/ConfigLoader.test.ts index 9f4e5e5864..7c7aa1dcf7 100644 --- a/lambdas/functions/webhook/src/ConfigLoader.test.ts +++ b/lambdas/functions/webhook/src/ConfigLoader.test.ts @@ -1,4 +1,5 @@ -import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; +import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { getRunnerMatcherConfigStore, type RunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; import { ConfigWebhook, ConfigWebhookEventBridge, ConfigDispatcher } from './ConfigLoader'; import { logger } from '@aws-github-runner/aws-powertools-util'; @@ -6,6 +7,11 @@ import { RunnerMatcherConfig } from './sqs'; import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('@aws-github-runner/aws-ssm-util'); +vi.mock('@aws-github-runner/storage-providers'); + +const runnerMatcherConfigStore = { + get: vi.fn(), +} satisfies RunnerMatcherConfigStore; describe('ConfigLoader Tests', () => { beforeEach(() => { @@ -14,6 +20,7 @@ describe('ConfigLoader Tests', () => { ConfigWebhookEventBridge.reset(); ConfigDispatcher.reset(); logger.setLogLevel('DEBUG'); + vi.mocked(getRunnerMatcherConfigStore).mockReturnValue(runnerMatcherConfigStore); // clear process.env for (const key of Object.keys(process.env)) { @@ -24,7 +31,6 @@ describe('ConfigLoader Tests', () => { describe('Check base object', () => { function setupConfiguration(): void { process.env.EVENT_BUS_NAME = 'event-bus'; - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; const matcherConfig = [ { @@ -36,15 +42,8 @@ describe('ConfigLoader Tests', () => { }, }, ]; - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - return JSON.stringify(matcherConfig); - } - if (paramPath === '/path/to/webhook/secret') { - return 'secret'; - } - return ''; - }); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); + vi.mocked(getParameter).mockResolvedValue('secret'); } it('should return the same instance of ConfigWebhook (singleton)', async () => { @@ -53,7 +52,8 @@ describe('ConfigLoader Tests', () => { const config2 = await ConfigWebhook.load(); expect(config1).toBe(config2); - expect(getParameter).toHaveBeenCalledTimes(2); + expect(getParameter).toHaveBeenCalledOnce(); + expect(runnerMatcherConfigStore.get).toHaveBeenCalledOnce(); }); it('should return the same instance of ConfigWebhookEventBridge (singleton)', async () => { @@ -63,6 +63,7 @@ describe('ConfigLoader Tests', () => { expect(config1).toBe(config2); expect(getParameter).toHaveBeenCalledTimes(1); + expect(runnerMatcherConfigStore.get).not.toHaveBeenCalled(); }); it('should return the same instance of ConfigDispatcher (singleton)', async () => { @@ -71,7 +72,8 @@ describe('ConfigLoader Tests', () => { const config2 = await ConfigDispatcher.load(); expect(config1).toBe(config2); - expect(getParameter).toHaveBeenCalledTimes(1); + expect(getParameter).not.toHaveBeenCalled(); + expect(runnerMatcherConfigStore.get).toHaveBeenCalledOnce(); }); it('should filter secrets from being logged', async () => { @@ -95,7 +97,6 @@ describe('ConfigLoader Tests', () => { it('should load config successfully', async () => { process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; const matcherConfig = [ { id: '1', @@ -106,15 +107,8 @@ describe('ConfigLoader Tests', () => { }, }, ]; - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - return JSON.stringify(matcherConfig); - } - if (paramPath === '/path/to/webhook/secret') { - return 'secret'; - } - return ''; - }); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); + vi.mocked(getParameter).mockResolvedValue('secret'); const config: ConfigWebhook = await ConfigWebhook.load(); @@ -124,7 +118,6 @@ describe('ConfigLoader Tests', () => { }); it('should load config successfully', async () => { - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; const matcherConfig = [ { @@ -136,15 +129,8 @@ describe('ConfigLoader Tests', () => { }, }, ]; - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - return JSON.stringify(matcherConfig); - } - if (paramPath === '/path/to/webhook/secret') { - return 'secret'; - } - return ''; - }); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); + vi.mocked(getParameter).mockResolvedValue('secret'); const config: ConfigWebhook = await ConfigWebhook.load(); @@ -155,46 +141,27 @@ describe('ConfigLoader Tests', () => { }); it('should throw error if config loading fails', async () => { - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; - - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - throw new Error('Failed to load matcher config'); - } - return ''; - }); + runnerMatcherConfigStore.get.mockRejectedValue( + new Error( + 'Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', + ), + ); + vi.mocked(getParameter).mockResolvedValue(''); await expect(ConfigWebhook.load()).rejects.toThrow( 'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', ); }); - it('should load config successfully from multiple paths', async () => { - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2'; + it('should load combined matcher config returned by the store', async () => { process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; - const partialMatcher1 = - '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["a"]],"exactMatch":true}}'; - const partialMatcher2 = - ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["b"]],"exactMatch":true}}]'; - const combinedMatcherConfig = [ { id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['a']], exactMatch: true } }, { id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['b']], exactMatch: true } }, ]; - - // Mock getParameters for batch fetching multiple paths - vi.mocked(getParameters).mockResolvedValue( - new Map([ - ['/path/to/matcher/config-1', partialMatcher1], - ['/path/to/matcher/config-2', partialMatcher2], - ]), - ); - - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/webhook/secret') return 'secret'; - return ''; - }); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(combinedMatcherConfig)); + vi.mocked(getParameter).mockResolvedValue('secret'); const config: ConfigWebhook = await ConfigWebhook.load(); @@ -202,27 +169,14 @@ describe('ConfigLoader Tests', () => { expect(config.webhookSecret).toBe('secret'); }); - it('should throw error if config loading fails from multiple paths', async () => { - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2'; + it('should propagate an error from the matcher config store', async () => { process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; - - const partialMatcher1 = - '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["a"]],"exactMatch":true}}'; - const partialMatcher2 = - ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["b"]],"exactMatch":true}}'; - - // Mock getParameters for batch fetching - returns incomplete JSON that will fail to parse - vi.mocked(getParameters).mockResolvedValue( - new Map([ - ['/path/to/matcher/config-1', partialMatcher1], - ['/path/to/matcher/config-2', partialMatcher2], - ]), + runnerMatcherConfigStore.get.mockRejectedValue( + new Error( + "Failed to load/parse combined matcher config: Expected ',' or ']' after array element in JSON at position 196", + ), ); - - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/webhook/secret') return 'secret'; - return ''; - }); + vi.mocked(getParameter).mockResolvedValue('secret'); await expect(ConfigWebhook.load()).rejects.toThrow( "Failed to load config: Failed to load/parse combined matcher config: Expected ',' or ']' after array element in JSON at position 196", @@ -248,6 +202,7 @@ describe('ConfigLoader Tests', () => { expect(config.allowedEvents).toEqual(['push', 'pull_request']); expect(config.eventBusName).toBe('event-bus'); expect(config.webhookSecret).toBe('secret'); + expect(runnerMatcherConfigStore.get).not.toHaveBeenCalled(); }); it('should throw error if config loading fails', async () => { @@ -264,7 +219,6 @@ describe('ConfigLoader Tests', () => { describe('ConfigDispatcher', () => { it('should load config successfully', async () => { process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; const matcherConfig: RunnerMatcherConfig[] = [ { @@ -276,12 +230,7 @@ describe('ConfigLoader Tests', () => { }, }, ]; - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - return JSON.stringify(matcherConfig); - } - return ''; - }); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); const config: ConfigDispatcher = await ConfigDispatcher.load(); @@ -289,27 +238,14 @@ describe('ConfigLoader Tests', () => { expect(config.matcherConfig).toEqual(matcherConfig); }); - it('should load config successfully from multiple paths with repo allow list', async () => { + it('should load combined matcher config returned by the store with repo allow list', async () => { process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2'; - - const partial1 = - '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["x"]],"exactMatch":true}}'; - const partial2 = - ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["y"]],"exactMatch":true}}]'; const combined: RunnerMatcherConfig[] = [ { id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['x']], exactMatch: true } }, { id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['y']], exactMatch: true } }, ]; - - // Mock getParameters for batch fetching multiple paths - vi.mocked(getParameters).mockResolvedValue( - new Map([ - ['/path/to/matcher/config-1', partial1], - ['/path/to/matcher/config-2', partial2], - ]), - ); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(combined)); const config: ConfigDispatcher = await ConfigDispatcher.load(); @@ -318,18 +254,15 @@ describe('ConfigLoader Tests', () => { }); it('should throw error if config loading fails', async () => { - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - throw new Error(`Parameter ${paramPath} not found`); - }); + runnerMatcherConfigStore.get.mockRejectedValue(new Error('Matcher config store is unavailable')); await expect(ConfigDispatcher.load()).rejects.toThrow( - 'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found', + 'Failed to load config: Matcher config store is unavailable', ); }); it('should rely on default when optionals are not set.', async () => { process.env.ACCEPT_EVENTS = 'null'; - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; const matcherConfig: RunnerMatcherConfig[] = [ { arn: 'arn:aws:sqs:eu-central-1:123456:npalm-default-queued-builds', @@ -340,12 +273,7 @@ describe('ConfigLoader Tests', () => { }, }, ]; - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - return JSON.stringify(matcherConfig); - } - return ''; - }); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); const config: ConfigDispatcher = await ConfigDispatcher.load(); @@ -355,14 +283,7 @@ describe('ConfigLoader Tests', () => { it('should throw an error if runner matcher config is empty.', async () => { process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; - - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - return JSON.stringify(''); - } - return ''; - }); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify('')); await expect(ConfigDispatcher.load()).rejects.toThrow('Failed to load config: Matcher config is empty'); }); diff --git a/lambdas/functions/webhook/src/ConfigLoader.ts b/lambdas/functions/webhook/src/ConfigLoader.ts index d9d9da2590..2cf261b849 100644 --- a/lambdas/functions/webhook/src/ConfigLoader.ts +++ b/lambdas/functions/webhook/src/ConfigLoader.ts @@ -1,4 +1,5 @@ -import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; +import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { getRunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; import { RunnerMatcherConfig } from './sqs'; import { logger } from '@aws-github-runner/aws-powertools-util'; @@ -66,7 +67,7 @@ abstract class BaseConfig { }); } - private loadProperty(propertyName: keyof this, value: string) { + protected loadProperty(propertyName: keyof this, value: string) { try { this[propertyName] = JSON.parse(value) as unknown as this[keyof this]; } catch { @@ -96,38 +97,11 @@ abstract class MatcherAwareConfig extends BaseConfig { // across the matching queues to avoid concentrating load on a single one. queueSelectionStrategy: QueueSelectionStrategy = 'first'; - protected async loadMatcherConfig(paramPathsEnv: string) { - if (!paramPathsEnv || paramPathsEnv === 'undefined' || paramPathsEnv === 'null' || !paramPathsEnv.includes(':')) { - // Single path or invalid string → load directly - await this.loadParameter(paramPathsEnv, 'matcherConfig'); - return; - } - - const paths = paramPathsEnv - .split(':') - .map((p) => p.trim()) - .filter(Boolean); - - // Batch fetch all matcher config paths in a single SSM API call + protected async loadMatcherConfig() { try { - const params = await getParameters(paths); - let combinedString = ''; - for (const path of paths) { - const value = params.get(path); - if (value) { - combinedString += value; - } else { - this.configLoadingErrors.push( - `Failed to load parameter for matcherConfig from path ${path}: Parameter not found`, - ); - } - } - - if (combinedString) { - this.matcherConfig = JSON.parse(combinedString); - } + this.loadProperty('matcherConfig', await getRunnerMatcherConfigStore().get()); } catch (error) { - this.configLoadingErrors.push(`Failed to load/parse combined matcher config: ${(error as Error).message}`); + this.configLoadingErrors.push((error as Error).message); } } } @@ -142,7 +116,7 @@ export class ConfigWebhook extends MatcherAwareConfig { this.loadEnvVar(process.env.QUEUE_SELECTION_STRATEGY, 'queueSelectionStrategy', 'first'); await Promise.all([ - this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH), + this.loadMatcherConfig(), this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'), ]); @@ -174,7 +148,7 @@ export class ConfigDispatcher extends MatcherAwareConfig { async loadConfig(): Promise { this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []); this.loadEnvVar(process.env.QUEUE_SELECTION_STRATEGY, 'queueSelectionStrategy', 'first'); - await this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH); + await this.loadMatcherConfig(); validateRunnerMatcherConfig(this); validateQueueSelectionStrategy(this); diff --git a/lambdas/functions/webhook/src/lambda.test.ts b/lambdas/functions/webhook/src/lambda.test.ts index d65b8371c4..3bc67e42dd 100644 --- a/lambdas/functions/webhook/src/lambda.test.ts +++ b/lambdas/functions/webhook/src/lambda.test.ts @@ -7,6 +7,7 @@ import { dispatchToRunners, eventBridgeWebhook, directWebhook } from './lambda'; import { publishForRunners, publishOnEventBridge } from './webhook'; import ValidationError from './ValidationError'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { getRunnerMatcherConfigStore, type RunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; import { dispatch } from './runners/dispatch'; import { EventWrapper } from './types'; import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -80,14 +81,20 @@ const context: Context = { vi.mock('./runners/dispatch'); vi.mock('./webhook'); vi.mock('@aws-github-runner/aws-ssm-util'); +vi.mock('@aws-github-runner/storage-providers'); + +const runnerMatcherConfigStore = { + get: vi.fn(), +} satisfies RunnerMatcherConfigStore; describe('Test webhook lambda wrapper.', () => { beforeEach(() => { - // We mock all SSM request to resolve to a non empty array. Since we mock all implemeantions - // relying on the config object that is enough to test the handlers. - const mockedGet = vi.mocked(getParameter); - mockedGet.mockResolvedValue('["abc"]'); vi.clearAllMocks(); + // The handlers only need non-empty config values because their downstream + // implementations are mocked in this wrapper test. + vi.mocked(getParameter).mockResolvedValue('["abc"]'); + vi.mocked(getRunnerMatcherConfigStore).mockReturnValue(runnerMatcherConfigStore); + runnerMatcherConfigStore.get.mockResolvedValue('["abc"]'); }); describe('Test webhook lambda wrapper.', () => { diff --git a/lambdas/functions/webhook/src/modules.d.ts b/lambdas/functions/webhook/src/modules.d.ts index 9110746709..3b04a2a5be 100644 --- a/lambdas/functions/webhook/src/modules.d.ts +++ b/lambdas/functions/webhook/src/modules.d.ts @@ -3,7 +3,6 @@ declare namespace NodeJS { ENVIRONMENT: string; EVENT_BUS_NAME: string; PARAMETER_GITHUB_APP_WEBHOOK_SECRET: string; - PARAMETER_RUNNER_MATCHER_CONFIG_PATH: string; QUEUE_SELECTION_STRATEGY: string; REPOSITORY_ALLOW_LIST: string; RUNNER_LABELS: string; diff --git a/lambdas/functions/webhook/src/runners/dispatch.test.ts b/lambdas/functions/webhook/src/runners/dispatch.test.ts index bb2cdc7cce..b3140d6e96 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.test.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.test.ts @@ -1,5 +1,5 @@ -import { getParameter } from '@aws-github-runner/aws-ssm-util'; import { selectDynamicLabelQueue } from '@aws-github-runner/compute-providers/webhook'; +import { getRunnerMatcherConfigStore, type RunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; import nock from 'nock'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; @@ -14,12 +14,14 @@ import { logger } from '@aws-github-runner/aws-powertools-util'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; vi.mock('../sqs'); -vi.mock('@aws-github-runner/aws-ssm-util'); +vi.mock('@aws-github-runner/storage-providers'); vi.mock('@aws-github-runner/compute-providers/webhook', () => ({ selectDynamicLabelQueue: vi.fn(), })); -const GITHUB_APP_WEBHOOK_SECRET = 'TEST_SECRET'; +const runnerMatcherConfigStore = { + get: vi.fn(), +} satisfies RunnerMatcherConfigStore; const cleanEnv = process.env; @@ -37,7 +39,6 @@ describe('Dispatcher', () => { vi.clearAllMocks(); vi.resetAllMocks(); - mockSSMResponse(); config = await createConfig(undefined, runnerConfig); }); @@ -242,7 +243,7 @@ describe('Dispatcher', () => { it('rejects an invalid strategy at config load', async () => { process.env.QUEUE_SELECTION_STRATEGY = 'bogus'; ConfigDispatcher.reset(); - mockSSMResponse(twoExactMatches); + mockMatcherConfigResponse(twoExactMatches); await expect(ConfigDispatcher.load()).rejects.toThrow(/queue selection strategy/i); }); }); @@ -394,16 +395,9 @@ describe('Dispatcher', () => { }); }); -function mockSSMResponse(runnerConfigInput?: RunnerConfig) { - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/github-runner/runner-matcher-config'; - const mockedGet = vi.mocked(getParameter); - mockedGet.mockImplementation((parameter_name) => { - const value = - parameter_name == '/github-runner/runner-matcher-config' - ? JSON.stringify(runnerConfigInput ?? runnerConfig) - : GITHUB_APP_WEBHOOK_SECRET; - return Promise.resolve(value); - }); +function mockMatcherConfigResponse(runnerConfigInput?: RunnerConfig) { + vi.mocked(getRunnerMatcherConfigStore).mockReturnValue(runnerMatcherConfigStore); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(runnerConfigInput ?? runnerConfig)); } async function createConfig(repositoryAllowList?: string[], runnerConfig?: RunnerConfig): Promise { @@ -411,6 +405,6 @@ async function createConfig(repositoryAllowList?: string[], runnerConfig?: Runne process.env.REPOSITORY_ALLOW_LIST = JSON.stringify(repositoryAllowList); } ConfigDispatcher.reset(); - mockSSMResponse(runnerConfig); + mockMatcherConfigResponse(runnerConfig); return await ConfigDispatcher.load(); } diff --git a/lambdas/functions/webhook/src/webhook/index.test.ts b/lambdas/functions/webhook/src/webhook/index.test.ts index aa4fbbc506..43345388f7 100644 --- a/lambdas/functions/webhook/src/webhook/index.test.ts +++ b/lambdas/functions/webhook/src/webhook/index.test.ts @@ -1,5 +1,6 @@ import { Webhooks } from '@octokit/webhooks'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { getRunnerMatcherConfigStore, type RunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; import nock from 'nock'; @@ -16,8 +17,12 @@ vi.mock('../sqs'); vi.mock('../eventbridge'); vi.mock('../runners/dispatch'); vi.mock('@aws-github-runner/aws-ssm-util'); +vi.mock('@aws-github-runner/storage-providers'); const GITHUB_APP_WEBHOOK_SECRET = 'TEST_SECRET'; +const runnerMatcherConfigStore = { + get: vi.fn(), +} satisfies RunnerMatcherConfigStore; const cleanEnv = process.env; @@ -32,7 +37,7 @@ describe('handle GitHub webhook events', () => { nock.disableNetConnect(); vi.clearAllMocks(); - mockSSMResponse(); + mockConfigResponse(); }); describe('handle and dispatch webhook events to build queues', () => { @@ -284,8 +289,7 @@ describe('Check message size (checkBodySize)', () => { }); }); -function mockSSMResponse() { - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; +function mockConfigResponse() { process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; const matcherConfig = [ { @@ -297,13 +301,7 @@ function mockSSMResponse() { }, }, ]; - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/matcher/config') { - return JSON.stringify(matcherConfig); - } - if (paramPath === '/path/to/webhook/secret') { - return GITHUB_APP_WEBHOOK_SECRET; - } - throw new Error('Parameter not found'); - }); + vi.mocked(getRunnerMatcherConfigStore).mockReturnValue(runnerMatcherConfigStore); + runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); + vi.mocked(getParameter).mockResolvedValue(GITHUB_APP_WEBHOOK_SECRET); } diff --git a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts index ba0e7afda0..bff946992f 100644 --- a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts +++ b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts @@ -6,6 +6,7 @@ declare global { PARAMETER_GITHUB_APP_ID_NAME?: string; PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME?: string; PARAMETER_GITHUB_APP_KEY_BASE64_NAME?: string; + PARAMETER_RUNNER_MATCHER_CONFIG_PATH?: string; SSM_CONFIG_PATH?: string; SSM_CLEANUP_CONFIG?: string; SSM_PARAMETER_STORE_TAGS?: string; diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.test.ts new file mode 100644 index 0000000000..4b9d2f3379 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.test.ts @@ -0,0 +1,103 @@ +import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmRunnerMatcherConfigStore } from './runner-matcher-config-store'; + +vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + getParameter: vi.fn(), + getParameters: vi.fn(), +})); + +const getParameterMock = vi.mocked(getParameter); +const getParametersMock = vi.mocked(getParameters); +const cleanEnv = process.env; + +describe('aws_ssm runner matcher config store', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH; + }); + + it('loads a single matcher config parameter', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/runner/matcher/config'; + getParameterMock.mockResolvedValue('[{"id":"runner"}]'); + + await expect(createAwsSsmRunnerMatcherConfigStore().get()).resolves.toBe('[{"id":"runner"}]'); + + expect(getParameterMock).toHaveBeenCalledWith('/runner/matcher/config'); + expect(getParametersMock).not.toHaveBeenCalled(); + }); + + it('loads and concatenates matcher config chunks in configured order', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = ' /runner/matcher/1 : : /runner/matcher/2 '; + getParametersMock.mockResolvedValue( + new Map([ + ['/runner/matcher/2', ',{"id":"runner-2"}]'], + ['/runner/matcher/1', '[{"id":"runner-1"}'], + ]), + ); + + await expect(createAwsSsmRunnerMatcherConfigStore().get()).resolves.toBe('[{"id":"runner-1"},{"id":"runner-2"}]'); + + expect(getParametersMock).toHaveBeenCalledWith(['/runner/matcher/1', '/runner/matcher/2']); + expect(getParameterMock).not.toHaveBeenCalled(); + }); + + it('rejects a missing matcher config chunk', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/runner/matcher/1:/runner/matcher/2'; + getParametersMock.mockResolvedValue(new Map([['/runner/matcher/1', '[{"id":"runner-1"}']])); + + await expect(createAwsSsmRunnerMatcherConfigStore().get()).rejects.toThrow( + 'Failed to load parameter for matcherConfig from path /runner/matcher/2: Parameter not found', + ); + }); + + it('rejects malformed combined matcher config', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/runner/matcher/1:/runner/matcher/2'; + getParametersMock.mockResolvedValue( + new Map([ + ['/runner/matcher/1', '[{"id":"runner-1"}'], + ['/runner/matcher/2', ',{"id":"runner-2"}'], + ]), + ); + + await expect(createAwsSsmRunnerMatcherConfigStore().get()).rejects.toThrow( + "Failed to load/parse combined matcher config: Expected ',' or ']' after array element", + ); + }); + + it('propagates a single parameter read failure', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/runner/matcher/config'; + const error = new Error('read failed'); + getParameterMock.mockRejectedValue(error); + + await expect(createAwsSsmRunnerMatcherConfigStore().get()).rejects.toThrow( + 'Failed to load parameter for matcherConfig from path /runner/matcher/config: read failed', + ); + }); + + it('propagates a batch parameter read failure', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/runner/matcher/1:/runner/matcher/2'; + const error = new Error('read failed'); + getParametersMock.mockRejectedValue(error); + + await expect(createAwsSsmRunnerMatcherConfigStore().get()).rejects.toThrow( + 'Failed to load/parse combined matcher config: read failed', + ); + }); + + it.each([undefined, '', ' '])('requires matcher config parameter paths for input %j', (parameterPaths) => { + if (parameterPaths === undefined) { + delete process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH; + } else { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = parameterPaths; + } + + expect(() => createAwsSsmRunnerMatcherConfigStore()).toThrow( + 'Environment variable PARAMETER_RUNNER_MATCHER_CONFIG_PATH is not set', + ); + expect(getParameterMock).not.toHaveBeenCalled(); + expect(getParametersMock).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.ts b/lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.ts new file mode 100644 index 0000000000..166151b850 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-matcher-config-store.ts @@ -0,0 +1,69 @@ +import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; + +import type { RunnerMatcherConfigStore } from '../../core'; +import type {} from './environment'; + +export function createAwsSsmRunnerMatcherConfigStore(): RunnerMatcherConfigStore { + const parameterPaths = process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH; + if (!parameterPaths || parameterPaths.trim() === '') { + throw new Error('Environment variable PARAMETER_RUNNER_MATCHER_CONFIG_PATH is not set'); + } + + const paths = parameterPaths + .split(':') + .map((path) => path.trim()) + .filter(Boolean); + + if (paths.length === 0) { + throw new Error('Environment variable PARAMETER_RUNNER_MATCHER_CONFIG_PATH is not set'); + } + + return new AwsSsmRunnerMatcherConfigStore(paths); +} + +class AwsSsmRunnerMatcherConfigStore implements RunnerMatcherConfigStore { + constructor(private readonly parameterPaths: string[]) {} + + async get(): Promise { + if (this.parameterPaths.length === 1) { + const path = this.parameterPaths[0]; + try { + return await getParameter(path); + } catch (error) { + throw new Error(`Failed to load parameter for matcherConfig from path ${path}: ${(error as Error).message}`); + } + } + + let parameters: Map; + try { + parameters = await getParameters(this.parameterPaths); + } catch (error) { + throw new Error(`Failed to load/parse combined matcher config: ${(error as Error).message}`); + } + + let combined = ''; + const errors: string[] = []; + for (const path of this.parameterPaths) { + const value = parameters.get(path); + if (value) { + combined += value; + } else { + errors.push(`Failed to load parameter for matcherConfig from path ${path}: Parameter not found`); + } + } + + if (combined) { + try { + JSON.parse(combined); + } catch (error) { + errors.push(`Failed to load/parse combined matcher config: ${(error as Error).message}`); + } + } + + if (errors.length > 0) { + throw new Error(errors.join(', ')); + } + + return combined; + } +} diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts index a044489348..c0ab6f39f6 100644 --- a/lambdas/libs/storage-providers/core/index.ts +++ b/lambdas/libs/storage-providers/core/index.ts @@ -33,3 +33,7 @@ export interface RunnerGroupCacheStore { get(runnerGroupName: string): Promise; create(record: RunnerGroupCacheRecord): Promise; } + +export interface RunnerMatcherConfigStore { + get(): Promise; +} diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index 05a1d5e285..d03c99e114 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -6,7 +6,9 @@ export type { RunnerConfigStore, RunnerGroupCacheRecord, RunnerGroupCacheStore, + RunnerMatcherConfigStore, } from './core'; export { getGitHubAppCredentialsStore, resetGitHubAppCredentialsStore } from './github-app-credentials'; export { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; export { getRunnerGroupCacheStore, resetRunnerGroupCacheStore } from './runner-group-cache'; +export { getRunnerMatcherConfigStore, resetRunnerMatcherConfigStore } from './runner-matcher-config'; diff --git a/lambdas/libs/storage-providers/runner-matcher-config.test.ts b/lambdas/libs/storage-providers/runner-matcher-config.test.ts new file mode 100644 index 0000000000..0dd7f42ad3 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-matcher-config.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmRunnerMatcherConfigStore } from './aws/ssm/runner-matcher-config-store'; +import type { RunnerMatcherConfigStore } from './core'; +import { getRunnerMatcherConfigStore, resetRunnerMatcherConfigStore } from './runner-matcher-config'; + +vi.mock('./aws/ssm/runner-matcher-config-store', () => ({ + createAwsSsmRunnerMatcherConfigStore: vi.fn(), +})); + +const createAwsSsmRunnerMatcherConfigStoreMock = vi.mocked(createAwsSsmRunnerMatcherConfigStore); +const cleanEnv = process.env; + +describe('runner matcher config store selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + resetRunnerMatcherConfigStore(); + }); + + it.each([undefined, '', ' '])('uses aws_ssm for default selector input %j', (provider) => { + setProvider(provider); + const store = stubStore(); + + expect(getRunnerMatcherConfigStore()).toBe(store); + expect(createAwsSsmRunnerMatcherConfigStoreMock).toHaveBeenCalledOnce(); + }); + + it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubStore(); + + expect(getRunnerMatcherConfigStore()).toBe(store); + expect(createAwsSsmRunnerMatcherConfigStoreMock).toHaveBeenCalledOnce(); + }); + + it('rejects an unsupported provider on first use', () => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + + expect(() => getRunnerMatcherConfigStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsSsmRunnerMatcherConfigStoreMock).not.toHaveBeenCalled(); + }); + + it('selects lazily and caches the created store', () => { + const store = stubStore(); + + expect(createAwsSsmRunnerMatcherConfigStoreMock).not.toHaveBeenCalled(); + const first = getRunnerMatcherConfigStore(); + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + const second = getRunnerMatcherConfigStore(); + + expect(first).toBe(store); + expect(second).toBe(store); + expect(createAwsSsmRunnerMatcherConfigStoreMock).toHaveBeenCalledOnce(); + }); + + it('selects again after the test reset', () => { + const firstStore = stubStore(); + expect(getRunnerMatcherConfigStore()).toBe(firstStore); + + const secondStore = { get: vi.fn() } satisfies RunnerMatcherConfigStore; + createAwsSsmRunnerMatcherConfigStoreMock.mockReturnValue(secondStore); + resetRunnerMatcherConfigStore(); + + expect(getRunnerMatcherConfigStore()).toBe(secondStore); + expect(createAwsSsmRunnerMatcherConfigStoreMock).toHaveBeenCalledTimes(2); + }); +}); + +function setProvider(provider: string | undefined): void { + if (provider === undefined) { + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + } else { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + } +} + +function stubStore(): RunnerMatcherConfigStore { + const store = { get: vi.fn() } satisfies RunnerMatcherConfigStore; + createAwsSsmRunnerMatcherConfigStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/runner-matcher-config.ts b/lambdas/libs/storage-providers/runner-matcher-config.ts new file mode 100644 index 0000000000..6d56d49754 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-matcher-config.ts @@ -0,0 +1,23 @@ +import { createAwsSsmRunnerMatcherConfigStore } from './aws/ssm/runner-matcher-config-store'; +import type { RunnerMatcherConfigStore } from './core'; +import type {} from './environment'; +import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; + +type RunnerMatcherConfigStoreFactory = () => RunnerMatcherConfigStore; + +const providerFactories = { + aws_ssm: createAwsSsmRunnerMatcherConfigStore, +} as const satisfies Record; + +let runnerMatcherConfigStore: RunnerMatcherConfigStore | undefined; + +export function getRunnerMatcherConfigStore(): RunnerMatcherConfigStore { + runnerMatcherConfigStore ??= + providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + return runnerMatcherConfigStore; +} + +// Test-only reset for cases that need to exercise first-use environment selection. +export function resetRunnerMatcherConfigStore(): void { + runnerMatcherConfigStore = undefined; +} diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts index d43a3721eb..f924cf5050 100644 --- a/lambdas/libs/storage-providers/vitest.config.ts +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -13,6 +13,7 @@ export default mergeConfig(defaultConfig, { 'github-app-credentials.ts', 'runner-config.ts', 'runner-group-cache.ts', + 'runner-matcher-config.ts', 'core/**/*.ts', 'aws/**/*.ts', ], diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index b7eaf7b3b4..e75cbc2821 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -251,6 +251,7 @@ __metadata: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" "@aws-github-runner/compute-providers": "npm:*" + "@aws-github-runner/storage-providers": "npm:*" "@aws-sdk/client-eventbridge": "npm:^3.1009.0" "@aws-sdk/client-sqs": "npm:^3.1009.0" "@middy/core": "npm:^6.4.5" From 0150fb2902cd6714f300f0c3959978fa2332cb93 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 11:21:36 +0200 Subject: [PATCH 44/49] refactor(storage): extract webhook secret store --- lambdas/functions/webhook/package.json | 1 - .../webhook/src/ConfigLoader.test.ts | 62 +++++++------- lambdas/functions/webhook/src/ConfigLoader.ts | 25 +++--- lambdas/functions/webhook/src/lambda.test.ts | 15 +++- lambdas/functions/webhook/src/modules.d.ts | 1 - .../webhook/src/webhook/index.test.ts | 16 ++-- .../aws/ssm/environment.d.ts | 1 + .../ssm/github-webhook-secret-store.test.ts | 51 ++++++++++++ .../aws/ssm/github-webhook-secret-store.ts | 27 ++++++ lambdas/libs/storage-providers/core/index.ts | 4 + .../github-webhook-secret.test.ts | 83 +++++++++++++++++++ .../github-webhook-secret.ts | 23 +++++ lambdas/libs/storage-providers/index.ts | 2 + .../libs/storage-providers/vitest.config.ts | 1 + lambdas/yarn.lock | 1 - 15 files changed, 257 insertions(+), 56 deletions(-) create mode 100644 lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.ts create mode 100644 lambdas/libs/storage-providers/github-webhook-secret.test.ts create mode 100644 lambdas/libs/storage-providers/github-webhook-secret.ts diff --git a/lambdas/functions/webhook/package.json b/lambdas/functions/webhook/package.json index 83d9810d1f..c596db3493 100644 --- a/lambdas/functions/webhook/package.json +++ b/lambdas/functions/webhook/package.json @@ -29,7 +29,6 @@ }, "dependencies": { "@aws-github-runner/aws-powertools-util": "*", - "@aws-github-runner/aws-ssm-util": "*", "@aws-github-runner/compute-providers": "*", "@aws-github-runner/storage-providers": "*", "@aws-sdk/client-sqs": "^3.1009.0", diff --git a/lambdas/functions/webhook/src/ConfigLoader.test.ts b/lambdas/functions/webhook/src/ConfigLoader.test.ts index 7c7aa1dcf7..41bc66f13b 100644 --- a/lambdas/functions/webhook/src/ConfigLoader.test.ts +++ b/lambdas/functions/webhook/src/ConfigLoader.test.ts @@ -1,14 +1,20 @@ -import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { getRunnerMatcherConfigStore, type RunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; +import { + getGitHubWebhookSecretStore, + getRunnerMatcherConfigStore, + type GitHubWebhookSecretStore, + type RunnerMatcherConfigStore, +} from '@aws-github-runner/storage-providers'; import { ConfigWebhook, ConfigWebhookEventBridge, ConfigDispatcher } from './ConfigLoader'; import { logger } from '@aws-github-runner/aws-powertools-util'; import { RunnerMatcherConfig } from './sqs'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -vi.mock('@aws-github-runner/aws-ssm-util'); vi.mock('@aws-github-runner/storage-providers'); +const githubWebhookSecretStore = { + get: vi.fn(), +} satisfies GitHubWebhookSecretStore; const runnerMatcherConfigStore = { get: vi.fn(), } satisfies RunnerMatcherConfigStore; @@ -20,6 +26,7 @@ describe('ConfigLoader Tests', () => { ConfigWebhookEventBridge.reset(); ConfigDispatcher.reset(); logger.setLogLevel('DEBUG'); + vi.mocked(getGitHubWebhookSecretStore).mockReturnValue(githubWebhookSecretStore); vi.mocked(getRunnerMatcherConfigStore).mockReturnValue(runnerMatcherConfigStore); // clear process.env @@ -31,7 +38,6 @@ describe('ConfigLoader Tests', () => { describe('Check base object', () => { function setupConfiguration(): void { process.env.EVENT_BUS_NAME = 'event-bus'; - process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; const matcherConfig = [ { id: '1', @@ -43,7 +49,7 @@ describe('ConfigLoader Tests', () => { }, ]; runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); - vi.mocked(getParameter).mockResolvedValue('secret'); + githubWebhookSecretStore.get.mockResolvedValue('secret'); } it('should return the same instance of ConfigWebhook (singleton)', async () => { @@ -52,7 +58,7 @@ describe('ConfigLoader Tests', () => { const config2 = await ConfigWebhook.load(); expect(config1).toBe(config2); - expect(getParameter).toHaveBeenCalledOnce(); + expect(githubWebhookSecretStore.get).toHaveBeenCalledOnce(); expect(runnerMatcherConfigStore.get).toHaveBeenCalledOnce(); }); @@ -62,7 +68,7 @@ describe('ConfigLoader Tests', () => { const config2 = await ConfigWebhookEventBridge.load(); expect(config1).toBe(config2); - expect(getParameter).toHaveBeenCalledTimes(1); + expect(githubWebhookSecretStore.get).toHaveBeenCalledOnce(); expect(runnerMatcherConfigStore.get).not.toHaveBeenCalled(); }); @@ -72,7 +78,7 @@ describe('ConfigLoader Tests', () => { const config2 = await ConfigDispatcher.load(); expect(config1).toBe(config2); - expect(getParameter).not.toHaveBeenCalled(); + expect(githubWebhookSecretStore.get).not.toHaveBeenCalled(); expect(runnerMatcherConfigStore.get).toHaveBeenCalledOnce(); }); @@ -96,7 +102,6 @@ describe('ConfigLoader Tests', () => { describe('ConfigWebhook', () => { it('should load config successfully', async () => { process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; - process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; const matcherConfig = [ { id: '1', @@ -108,7 +113,7 @@ describe('ConfigLoader Tests', () => { }, ]; runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); - vi.mocked(getParameter).mockResolvedValue('secret'); + githubWebhookSecretStore.get.mockResolvedValue('secret'); const config: ConfigWebhook = await ConfigWebhook.load(); @@ -118,7 +123,6 @@ describe('ConfigLoader Tests', () => { }); it('should load config successfully', async () => { - process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; const matcherConfig = [ { id: '1', @@ -130,7 +134,7 @@ describe('ConfigLoader Tests', () => { }, ]; runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); - vi.mocked(getParameter).mockResolvedValue('secret'); + githubWebhookSecretStore.get.mockResolvedValue('secret'); const config: ConfigWebhook = await ConfigWebhook.load(); @@ -146,7 +150,7 @@ describe('ConfigLoader Tests', () => { 'Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', ), ); - vi.mocked(getParameter).mockResolvedValue(''); + githubWebhookSecretStore.get.mockResolvedValue(''); await expect(ConfigWebhook.load()).rejects.toThrow( 'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', @@ -154,14 +158,12 @@ describe('ConfigLoader Tests', () => { }); it('should load combined matcher config returned by the store', async () => { - process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; - const combinedMatcherConfig = [ { id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['a']], exactMatch: true } }, { id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['b']], exactMatch: true } }, ]; runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(combinedMatcherConfig)); - vi.mocked(getParameter).mockResolvedValue('secret'); + githubWebhookSecretStore.get.mockResolvedValue('secret'); const config: ConfigWebhook = await ConfigWebhook.load(); @@ -170,13 +172,12 @@ describe('ConfigLoader Tests', () => { }); it('should propagate an error from the matcher config store', async () => { - process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; runnerMatcherConfigStore.get.mockRejectedValue( new Error( "Failed to load/parse combined matcher config: Expected ',' or ']' after array element in JSON at position 196", ), ); - vi.mocked(getParameter).mockResolvedValue('secret'); + githubWebhookSecretStore.get.mockResolvedValue('secret'); await expect(ConfigWebhook.load()).rejects.toThrow( "Failed to load config: Failed to load/parse combined matcher config: Expected ',' or ']' after array element in JSON at position 196", @@ -188,14 +189,7 @@ describe('ConfigLoader Tests', () => { it('should load config successfully', async () => { process.env.ACCEPT_EVENTS = '["push", "pull_request"]'; process.env.EVENT_BUS_NAME = 'event-bus'; - process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; - - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - if (paramPath === '/path/to/webhook/secret') { - return 'secret'; - } - return ''; - }); + githubWebhookSecretStore.get.mockResolvedValue('secret'); const config: ConfigWebhookEventBridge = await ConfigWebhookEventBridge.load(); @@ -206,13 +200,23 @@ describe('ConfigLoader Tests', () => { }); it('should throw error if config loading fails', async () => { - vi.mocked(getParameter).mockImplementation(async (paramPath: string) => { - throw new Error(`Parameter ${paramPath} not found`); + githubWebhookSecretStore.get.mockRejectedValue(new Error('Webhook secret store is unavailable')); + + await expect(ConfigWebhookEventBridge.load()).rejects.toThrow( + 'Failed to load config: Environment variable for eventBusName is not set and no default value provided., Webhook secret store is unavailable', + ); + }); + + it('should report an error selecting the webhook secret store', async () => { + process.env.EVENT_BUS_NAME = 'event-bus'; + vi.mocked(getGitHubWebhookSecretStore).mockImplementationOnce(() => { + throw new Error("Unsupported runner config storage provider 'not-registered'"); }); await expect(ConfigWebhookEventBridge.load()).rejects.toThrow( - 'Failed to load config: Environment variable for eventBusName is not set and no default value provided., Failed to load parameter for webhookSecret from path undefined: Parameter undefined not found', + "Failed to load config: Unsupported runner config storage provider 'not-registered'", ); + expect(githubWebhookSecretStore.get).not.toHaveBeenCalled(); }); }); diff --git a/lambdas/functions/webhook/src/ConfigLoader.ts b/lambdas/functions/webhook/src/ConfigLoader.ts index 2cf261b849..e6d1d65004 100644 --- a/lambdas/functions/webhook/src/ConfigLoader.ts +++ b/lambdas/functions/webhook/src/ConfigLoader.ts @@ -1,10 +1,9 @@ -import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { getRunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; +import { getGitHubWebhookSecretStore, getRunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; import { RunnerMatcherConfig } from './sqs'; import { logger } from '@aws-github-runner/aws-powertools-util'; /** - * Base class for loading configuration from environment variables and SSM parameters. + * Base class for loading configuration from environment variables and configuration stores. * * @remarks * To avoid usages or checking values can be undefined we assume that configuration is @@ -55,16 +54,12 @@ abstract class BaseConfig { } } - protected async loadParameter(paramPath: string, propertyName: keyof this): Promise { - logger.debug(`Loading parameter for ${String(propertyName)} from path ${paramPath}`); - await getParameter(paramPath) - .then((value) => { - this.loadProperty(propertyName, value); - }) - .catch((error) => { - const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`; - this.configLoadingErrors.push(errorMessage); - }); + protected async loadStoredProperty(propertyName: keyof this, getValue: () => Promise): Promise { + try { + this.loadProperty(propertyName, await getValue()); + } catch (error) { + this.configLoadingErrors.push((error as Error).message); + } } protected loadProperty(propertyName: keyof this, value: string) { @@ -117,7 +112,7 @@ export class ConfigWebhook extends MatcherAwareConfig { await Promise.all([ this.loadMatcherConfig(), - this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'), + this.loadStoredProperty('webhookSecret', () => getGitHubWebhookSecretStore().get()), ]); validateWebhookSecret(this); @@ -134,7 +129,7 @@ export class ConfigWebhookEventBridge extends BaseConfig { async loadConfig(): Promise { this.loadEnvVar(process.env.ACCEPT_EVENTS, 'allowedEvents', []); this.loadEnvVar(process.env.EVENT_BUS_NAME, 'eventBusName'); - await this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'); + await this.loadStoredProperty('webhookSecret', () => getGitHubWebhookSecretStore().get()); validateEventBusName(this); validateWebhookSecret(this); diff --git a/lambdas/functions/webhook/src/lambda.test.ts b/lambdas/functions/webhook/src/lambda.test.ts index 3bc67e42dd..b325c002f4 100644 --- a/lambdas/functions/webhook/src/lambda.test.ts +++ b/lambdas/functions/webhook/src/lambda.test.ts @@ -6,8 +6,12 @@ import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { dispatchToRunners, eventBridgeWebhook, directWebhook } from './lambda'; import { publishForRunners, publishOnEventBridge } from './webhook'; import ValidationError from './ValidationError'; -import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { getRunnerMatcherConfigStore, type RunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; +import { + getGitHubWebhookSecretStore, + getRunnerMatcherConfigStore, + type GitHubWebhookSecretStore, + type RunnerMatcherConfigStore, +} from '@aws-github-runner/storage-providers'; import { dispatch } from './runners/dispatch'; import { EventWrapper } from './types'; import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -80,9 +84,11 @@ const context: Context = { vi.mock('./runners/dispatch'); vi.mock('./webhook'); -vi.mock('@aws-github-runner/aws-ssm-util'); vi.mock('@aws-github-runner/storage-providers'); +const githubWebhookSecretStore = { + get: vi.fn(), +} satisfies GitHubWebhookSecretStore; const runnerMatcherConfigStore = { get: vi.fn(), } satisfies RunnerMatcherConfigStore; @@ -92,7 +98,8 @@ describe('Test webhook lambda wrapper.', () => { vi.clearAllMocks(); // The handlers only need non-empty config values because their downstream // implementations are mocked in this wrapper test. - vi.mocked(getParameter).mockResolvedValue('["abc"]'); + vi.mocked(getGitHubWebhookSecretStore).mockReturnValue(githubWebhookSecretStore); + githubWebhookSecretStore.get.mockResolvedValue('["abc"]'); vi.mocked(getRunnerMatcherConfigStore).mockReturnValue(runnerMatcherConfigStore); runnerMatcherConfigStore.get.mockResolvedValue('["abc"]'); }); diff --git a/lambdas/functions/webhook/src/modules.d.ts b/lambdas/functions/webhook/src/modules.d.ts index 3b04a2a5be..05a81a12ab 100644 --- a/lambdas/functions/webhook/src/modules.d.ts +++ b/lambdas/functions/webhook/src/modules.d.ts @@ -2,7 +2,6 @@ declare namespace NodeJS { export interface ProcessEnv { ENVIRONMENT: string; EVENT_BUS_NAME: string; - PARAMETER_GITHUB_APP_WEBHOOK_SECRET: string; QUEUE_SELECTION_STRATEGY: string; REPOSITORY_ALLOW_LIST: string; RUNNER_LABELS: string; diff --git a/lambdas/functions/webhook/src/webhook/index.test.ts b/lambdas/functions/webhook/src/webhook/index.test.ts index 43345388f7..6d7a272309 100644 --- a/lambdas/functions/webhook/src/webhook/index.test.ts +++ b/lambdas/functions/webhook/src/webhook/index.test.ts @@ -1,6 +1,10 @@ import { Webhooks } from '@octokit/webhooks'; -import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { getRunnerMatcherConfigStore, type RunnerMatcherConfigStore } from '@aws-github-runner/storage-providers'; +import { + getGitHubWebhookSecretStore, + getRunnerMatcherConfigStore, + type GitHubWebhookSecretStore, + type RunnerMatcherConfigStore, +} from '@aws-github-runner/storage-providers'; import nock from 'nock'; @@ -16,10 +20,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('../sqs'); vi.mock('../eventbridge'); vi.mock('../runners/dispatch'); -vi.mock('@aws-github-runner/aws-ssm-util'); vi.mock('@aws-github-runner/storage-providers'); const GITHUB_APP_WEBHOOK_SECRET = 'TEST_SECRET'; +const githubWebhookSecretStore = { + get: vi.fn(), +} satisfies GitHubWebhookSecretStore; const runnerMatcherConfigStore = { get: vi.fn(), } satisfies RunnerMatcherConfigStore; @@ -290,7 +296,6 @@ describe('Check message size (checkBodySize)', () => { }); function mockConfigResponse() { - process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; const matcherConfig = [ { id: '1', @@ -301,7 +306,8 @@ function mockConfigResponse() { }, }, ]; + vi.mocked(getGitHubWebhookSecretStore).mockReturnValue(githubWebhookSecretStore); vi.mocked(getRunnerMatcherConfigStore).mockReturnValue(runnerMatcherConfigStore); + githubWebhookSecretStore.get.mockResolvedValue(GITHUB_APP_WEBHOOK_SECRET); runnerMatcherConfigStore.get.mockResolvedValue(JSON.stringify(matcherConfig)); - vi.mocked(getParameter).mockResolvedValue(GITHUB_APP_WEBHOOK_SECRET); } diff --git a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts index bff946992f..a1af90d6e7 100644 --- a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts +++ b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts @@ -6,6 +6,7 @@ declare global { PARAMETER_GITHUB_APP_ID_NAME?: string; PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME?: string; PARAMETER_GITHUB_APP_KEY_BASE64_NAME?: string; + PARAMETER_GITHUB_APP_WEBHOOK_SECRET?: string; PARAMETER_RUNNER_MATCHER_CONFIG_PATH?: string; SSM_CONFIG_PATH?: string; SSM_CLEANUP_CONFIG?: string; diff --git a/lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.test.ts new file mode 100644 index 0000000000..7384c769bc --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.test.ts @@ -0,0 +1,51 @@ +import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmGitHubWebhookSecretStore } from './github-webhook-secret-store'; + +vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + getParameter: vi.fn(), +})); + +const getParameterMock = vi.mocked(getParameter); +const cleanEnv = process.env; +const webhookSecretParameter = '/actions-runner/test/webhook_secret'; + +describe('aws_ssm GitHub webhook secret store', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = webhookSecretParameter; + }); + + it('loads the webhook secret parameter', async () => { + getParameterMock.mockResolvedValue('fake-webhook-secret'); + const store = createAwsSsmGitHubWebhookSecretStore(); + + await expect(store.get()).resolves.toBe('fake-webhook-secret'); + expect(getParameterMock).toHaveBeenCalledOnce(); + expect(getParameterMock).toHaveBeenCalledWith(webhookSecretParameter); + }); + + it('wraps a parameter read failure with the legacy error message', async () => { + getParameterMock.mockRejectedValue(new Error('access denied')); + const store = createAwsSsmGitHubWebhookSecretStore(); + + await expect(store.get()).rejects.toThrow( + `Failed to load parameter for webhookSecret from path ${webhookSecretParameter}: access denied`, + ); + }); + + it.each([undefined, '', ' '])('requires a webhook secret parameter path for input %j', (parameterPath) => { + if (parameterPath === undefined) { + delete process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET; + } else { + process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = parameterPath; + } + + expect(() => createAwsSsmGitHubWebhookSecretStore()).toThrow( + 'Environment variable PARAMETER_GITHUB_APP_WEBHOOK_SECRET is not set', + ); + expect(getParameterMock).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.ts b/lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.ts new file mode 100644 index 0000000000..ce35e1f532 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/github-webhook-secret-store.ts @@ -0,0 +1,27 @@ +import { getParameter } from '@aws-github-runner/aws-ssm-util'; + +import type { GitHubWebhookSecretStore } from '../../core'; +import type {} from './environment'; + +export function createAwsSsmGitHubWebhookSecretStore(): GitHubWebhookSecretStore { + const parameterPath = process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET; + if (!parameterPath || parameterPath.trim() === '') { + throw new Error('Environment variable PARAMETER_GITHUB_APP_WEBHOOK_SECRET is not set'); + } + + return new AwsSsmGitHubWebhookSecretStore(parameterPath); +} + +class AwsSsmGitHubWebhookSecretStore implements GitHubWebhookSecretStore { + constructor(private readonly parameterPath: string) {} + + async get(): Promise { + try { + return await getParameter(this.parameterPath); + } catch (error) { + throw new Error( + `Failed to load parameter for webhookSecret from path ${this.parameterPath}: ${(error as Error).message}`, + ); + } + } +} diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts index c0ab6f39f6..46117ee622 100644 --- a/lambdas/libs/storage-providers/core/index.ts +++ b/lambdas/libs/storage-providers/core/index.ts @@ -8,6 +8,10 @@ export interface GitHubAppCredentialsStore { get(): Promise; } +export interface GitHubWebhookSecretStore { + get(): Promise; +} + export interface RunnerConfigMetadata { key: string; value: string; diff --git a/lambdas/libs/storage-providers/github-webhook-secret.test.ts b/lambdas/libs/storage-providers/github-webhook-secret.test.ts new file mode 100644 index 0000000000..5888e735b0 --- /dev/null +++ b/lambdas/libs/storage-providers/github-webhook-secret.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsSsmGitHubWebhookSecretStore } from './aws/ssm/github-webhook-secret-store'; +import type { GitHubWebhookSecretStore } from './core'; +import { getGitHubWebhookSecretStore, resetGitHubWebhookSecretStore } from './github-webhook-secret'; + +vi.mock('./aws/ssm/github-webhook-secret-store', () => ({ + createAwsSsmGitHubWebhookSecretStore: vi.fn(), +})); + +const createAwsSsmGitHubWebhookSecretStoreMock = vi.mocked(createAwsSsmGitHubWebhookSecretStore); +const cleanEnv = process.env; + +describe('GitHub webhook secret store selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + resetGitHubWebhookSecretStore(); + }); + + it.each([undefined, '', ' '])('uses aws_ssm for default selector input %j', (provider) => { + setProvider(provider); + const store = stubStore(); + + expect(getGitHubWebhookSecretStore()).toBe(store); + expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); + }); + + it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubStore(); + + expect(getGitHubWebhookSecretStore()).toBe(store); + expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); + }); + + it('rejects an unsupported provider on first use', () => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + + expect(() => getGitHubWebhookSecretStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsSsmGitHubWebhookSecretStoreMock).not.toHaveBeenCalled(); + }); + + it('selects lazily and caches the created store', () => { + const store = stubStore(); + + expect(createAwsSsmGitHubWebhookSecretStoreMock).not.toHaveBeenCalled(); + const first = getGitHubWebhookSecretStore(); + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; + const second = getGitHubWebhookSecretStore(); + + expect(first).toBe(store); + expect(second).toBe(store); + expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); + }); + + it('selects again after the test reset', () => { + const firstStore = stubStore(); + expect(getGitHubWebhookSecretStore()).toBe(firstStore); + + const secondStore = { get: vi.fn() } satisfies GitHubWebhookSecretStore; + createAwsSsmGitHubWebhookSecretStoreMock.mockReturnValue(secondStore); + resetGitHubWebhookSecretStore(); + + expect(getGitHubWebhookSecretStore()).toBe(secondStore); + expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledTimes(2); + }); +}); + +function setProvider(provider: string | undefined): void { + if (provider === undefined) { + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + } else { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + } +} + +function stubStore(): GitHubWebhookSecretStore { + const store = { get: vi.fn() } satisfies GitHubWebhookSecretStore; + createAwsSsmGitHubWebhookSecretStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/github-webhook-secret.ts b/lambdas/libs/storage-providers/github-webhook-secret.ts new file mode 100644 index 0000000000..f13df08718 --- /dev/null +++ b/lambdas/libs/storage-providers/github-webhook-secret.ts @@ -0,0 +1,23 @@ +import { createAwsSsmGitHubWebhookSecretStore } from './aws/ssm/github-webhook-secret-store'; +import type { GitHubWebhookSecretStore } from './core'; +import type {} from './environment'; +import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; + +type GitHubWebhookSecretStoreFactory = () => GitHubWebhookSecretStore; + +const providerFactories = { + aws_ssm: createAwsSsmGitHubWebhookSecretStore, +} as const satisfies Record; + +let githubWebhookSecretStore: GitHubWebhookSecretStore | undefined; + +export function getGitHubWebhookSecretStore(): GitHubWebhookSecretStore { + githubWebhookSecretStore ??= + providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + return githubWebhookSecretStore; +} + +// Test-only reset for cases that need to exercise first-use environment selection. +export function resetGitHubWebhookSecretStore(): void { + githubWebhookSecretStore = undefined; +} diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index d03c99e114..49e9d11c18 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -1,6 +1,7 @@ export type { GitHubAppCredential, GitHubAppCredentialsStore, + GitHubWebhookSecretStore, RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore, @@ -9,6 +10,7 @@ export type { RunnerMatcherConfigStore, } from './core'; export { getGitHubAppCredentialsStore, resetGitHubAppCredentialsStore } from './github-app-credentials'; +export { getGitHubWebhookSecretStore, resetGitHubWebhookSecretStore } from './github-webhook-secret'; export { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; export { getRunnerGroupCacheStore, resetRunnerGroupCacheStore } from './runner-group-cache'; export { getRunnerMatcherConfigStore, resetRunnerMatcherConfigStore } from './runner-matcher-config'; diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts index f924cf5050..0b0b3356e8 100644 --- a/lambdas/libs/storage-providers/vitest.config.ts +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -11,6 +11,7 @@ export default mergeConfig(defaultConfig, { 'index.ts', 'provider.ts', 'github-app-credentials.ts', + 'github-webhook-secret.ts', 'runner-config.ts', 'runner-group-cache.ts', 'runner-matcher-config.ts', diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index e75cbc2821..1322072419 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -249,7 +249,6 @@ __metadata: resolution: "@aws-github-runner/webhook@workspace:functions/webhook" dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" - "@aws-github-runner/aws-ssm-util": "npm:*" "@aws-github-runner/compute-providers": "npm:*" "@aws-github-runner/storage-providers": "npm:*" "@aws-sdk/client-eventbridge": "npm:^3.1009.0" From dc99f2849a5ec43163ea51ed403901627df65199 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 11:52:51 +0200 Subject: [PATCH 45/49] feat(storage): add DynamoDB provider --- .../storage-providers/aws/dynamodb/client.ts | 21 ++ .../aws/dynamodb/environment.d.ts | 13 + .../aws/dynamodb/environment.ts | 29 ++ .../aws/dynamodb/runner-config-store.test.ts | 117 ++++++ .../aws/dynamodb/runner-config-store.ts | 64 ++++ .../dynamodb/runner-group-cache-store.test.ts | 105 ++++++ .../aws/dynamodb/runner-group-cache-store.ts | 78 ++++ .../runner-matcher-config-store.test.ts | 94 +++++ .../dynamodb/runner-matcher-config-store.ts | 53 +++ .../github-app-credentials.test.ts | 35 +- .../github-app-credentials.ts | 12 +- .../github-webhook-secret.test.ts | 35 +- .../github-webhook-secret.ts | 12 +- lambdas/libs/storage-providers/package.json | 1 + lambdas/libs/storage-providers/provider.ts | 2 +- .../storage-providers/runner-config.test.ts | 24 ++ .../libs/storage-providers/runner-config.ts | 2 + .../runner-group-cache.test.ts | 23 ++ .../storage-providers/runner-group-cache.ts | 2 + .../runner-matcher-config.test.ts | 23 ++ .../runner-matcher-config.ts | 2 + lambdas/yarn.lock | 342 ++++++++++++++++++ 22 files changed, 1020 insertions(+), 69 deletions(-) create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/client.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/environment.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts diff --git a/lambdas/libs/storage-providers/aws/dynamodb/client.ts b/lambdas/libs/storage-providers/aws/dynamodb/client.ts new file mode 100644 index 0000000000..86493cc23f --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/client.ts @@ -0,0 +1,21 @@ +import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; + +let memoisedClient: DynamoDBClient | undefined; + +export function getDynamoDbClient(): DynamoDBClient { + memoisedClient ??= getTracedAWSV3Client( + new DynamoDBClient({ + region: process.env.AWS_REGION, + maxAttempts: 10, + // One client serves two tables, so avoid an adaptive rate bucket coupling their throttling behavior. + retryMode: 'standard', + }), + ); + return memoisedClient; +} + +// Test-only reset for cases that need a fresh AWS SDK client. +export function resetDynamoDbClient(): void { + memoisedClient = undefined; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts b/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts new file mode 100644 index 0000000000..80f6cd76b4 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX?: string; + RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME?: string; + RUNNER_CONFIG_DYNAMODB_TABLE_NAME?: string; + RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX?: string; + RUNNER_CONFIG_DYNAMODB_TTL_SECONDS?: string; + } + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/environment.ts b/lambdas/libs/storage-providers/aws/dynamodb/environment.ts new file mode 100644 index 0000000000..008df063f7 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/environment.ts @@ -0,0 +1,29 @@ +type DynamoDbEnvironmentVariable = + | 'RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX' + | 'RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME' + | 'RUNNER_CONFIG_DYNAMODB_TABLE_NAME' + | 'RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX' + | 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'; + +export function requiredEnvironmentValue(name: DynamoDbEnvironmentVariable): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Environment variable ${name} is not set`); + } + + return value; +} + +export function positiveIntegerEnvironmentValue(name: DynamoDbEnvironmentVariable): number { + const value = requiredEnvironmentValue(name); + if (!/^[1-9]\d*$/.test(value)) { + throw new Error(`Environment variable ${name} must be a positive integer`); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`Environment variable ${name} must be a positive integer`); + } + + return parsed; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts new file mode 100644 index 0000000000..0801ba666f --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts @@ -0,0 +1,117 @@ +import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerConfigStore } from './runner-config-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner config store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_TABLE_NAME = 'runner-config'; + process.env.RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX = 'tokens#'; + process.env.RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = '3600'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates an expiring runner config without overwriting an existing record', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await store.create({ runnerId: 'runner-123', value: 'encoded-jit-config' }); + + expect(store.maxWritesPerSecond).toBeUndefined(); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { + TableName: 'runner-config', + Item: { + id: { S: 'tokens#runner-123' }, + value: { S: 'encoded-jit-config' }, + expires_at: { N: '1735693200' }, + }, + ConditionExpression: 'attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#id': 'id', + }, + }); + }); + + it('stores provider-neutral metadata without exposing it as top-level attributes', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await store.create( + { runnerId: 'runner-123', value: 'registration-config' }, + { + metadata: [ + { key: 'InstanceId', value: 'i-123' }, + { key: 'Environment', value: 'test' }, + ], + }, + ); + + const command = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0]; + expect(command.input.Item?.metadata).toEqual({ + L: [ + { M: { key: { S: 'InstanceId' }, value: { S: 'i-123' } } }, + { M: { key: { S: 'Environment' }, value: { S: 'test' } } }, + ], + }); + }); + + it('does not write metadata for an empty metadata list', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await store.create({ runnerId: 'runner-123', value: 'registration-config' }, { metadata: [] }); + + const command = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0]; + expect(command.input.Item).not.toHaveProperty('metadata'); + }); + + it('relies on DynamoDB TTL instead of scanning or deleting during housekeeping', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await expect(store.houseKeeper()).resolves.toBeUndefined(); + + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it.each([ + 'RUNNER_CONFIG_DYNAMODB_TABLE_NAME', + 'RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX', + 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS', + ] as const)('rejects a missing or blank %s', (name) => { + delete process.env[name]; + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); + + process.env[name] = ' '; + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it.each(['0', '-1', '1.5', 'not-a-number', '9007199254740992'])('rejects invalid TTL seconds %j', (ttlSeconds) => { + process.env.RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = ttlSeconds; + + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_TTL_SECONDS must be a positive integer', + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it('propagates DynamoDB write errors without handling the stored value', async () => { + const error = new Error('conditional request failed'); + mockDynamoDbClient.on(PutItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerConfigStore(); + + await expect(store.create({ runnerId: 'runner-123', value: 'sensitive-config' })).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts new file mode 100644 index 0000000000..5d4baf2612 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts @@ -0,0 +1,64 @@ +import { PutItemCommand, type AttributeValue } from '@aws-sdk/client-dynamodb'; + +import type { RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore } from '../../core'; +import { getDynamoDbClient } from './client'; +import { positiveIntegerEnvironmentValue, requiredEnvironmentValue } from './environment'; + +const ID_ATTRIBUTE = 'id'; +const VALUE_ATTRIBUTE = 'value'; +const EXPIRES_AT_ATTRIBUTE = 'expires_at'; +const METADATA_ATTRIBUTE = 'metadata'; + +interface AwsDynamoDbRunnerConfigStoreConfig { + tableName: string; + tokenKeyPrefix: string; + ttlSeconds: number; +} + +export function createAwsDynamoDbRunnerConfigStore(): RunnerConfigStore { + return new AwsDynamoDbRunnerConfigStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_TABLE_NAME'), + tokenKeyPrefix: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX'), + ttlSeconds: positiveIntegerEnvironmentValue('RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'), + }); +} + +class AwsDynamoDbRunnerConfigStore implements RunnerConfigStore { + constructor(private readonly config: AwsDynamoDbRunnerConfigStoreConfig) {} + + async create(record: RunnerConfigRecord, options: { metadata?: RunnerConfigMetadata[] } = {}): Promise { + const item: Record = { + [ID_ATTRIBUTE]: { S: `${this.config.tokenKeyPrefix}${record.runnerId}` }, + [VALUE_ATTRIBUTE]: { S: record.value }, + [EXPIRES_AT_ATTRIBUTE]: { + N: (Math.floor(Date.now() / 1000) + this.config.ttlSeconds).toString(), + }, + }; + + if (options.metadata && options.metadata.length > 0) { + item[METADATA_ATTRIBUTE] = { + L: options.metadata.map(({ key, value }) => ({ + M: { + key: { S: key }, + value: { S: value }, + }, + })), + }; + } + + await getDynamoDbClient().send( + new PutItemCommand({ + TableName: this.config.tableName, + Item: item, + ConditionExpression: 'attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#id': ID_ATTRIBUTE, + }, + }), + ); + } + + async houseKeeper(): Promise { + // DynamoDB TTL removes expired runner config records without a scan/delete job. + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts new file mode 100644 index 0000000000..2bb335874b --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts @@ -0,0 +1,105 @@ +import { DynamoDBClient, GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerGroupCacheStore } from './runner-group-cache-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner group cache store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX = 'config#'; + }); + + it('gets a runner group id with a strongly consistent projected read', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '42' } } }); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).resolves.toBe(42); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + id: { S: 'config#runner-group#Default' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': 'value', + }, + }); + }); + + it('returns undefined when the runner group is not cached', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).resolves.toBeUndefined(); + }); + + it.each([ + [{ value: { N: '42' } }, 'non-string value'], + [{ value: { S: '42cached' } }, 'partially numeric value'], + [{ value: { S: '9007199254740992' } }, 'unsafe integer value'], + ])('rejects an invalid cached runner group id: %s', async (item) => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: item }); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).rejects.toThrow( + "Runner group cache item 'config#runner-group#Default' has an invalid value", + ); + }); + + it('creates a runner group cache record without overwriting an existing record', async () => { + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await store.create({ runnerGroupName: 'Default', runnerGroupId: 42 }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { + TableName: 'runner-configuration', + Item: { + id: { S: 'config#runner-group#Default' }, + value: { S: '42' }, + }, + ConditionExpression: 'attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#id': 'id', + }, + }); + }); + + it.each(['RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'] as const)( + 'rejects a missing or blank %s', + (name) => { + delete process.env[name]; + expect(() => createAwsDynamoDbRunnerGroupCacheStore()).toThrow(`Environment variable ${name} is not set`); + + process.env[name] = ' '; + expect(() => createAwsDynamoDbRunnerGroupCacheStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }, + ); + + it('propagates DynamoDB read errors', async () => { + const error = new Error('read failed'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).rejects.toBe(error); + }); + + it('propagates DynamoDB write errors', async () => { + const error = new Error('conditional request failed'); + mockDynamoDbClient.on(PutItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.create({ runnerGroupName: 'Default', runnerGroupId: 42 })).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts new file mode 100644 index 0000000000..4ac2b3587e --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts @@ -0,0 +1,78 @@ +import { GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'; + +import type { RunnerGroupCacheRecord, RunnerGroupCacheStore } from '../../core'; +import { getDynamoDbClient } from './client'; +import { requiredEnvironmentValue } from './environment'; + +const ID_ATTRIBUTE = 'id'; +const VALUE_ATTRIBUTE = 'value'; +const RUNNER_GROUP_KEY = 'runner-group#'; + +interface AwsDynamoDbRunnerGroupCacheStoreConfig { + tableName: string; + configKeyPrefix: string; +} + +export function createAwsDynamoDbRunnerGroupCacheStore(): RunnerGroupCacheStore { + return new AwsDynamoDbRunnerGroupCacheStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'), + configKeyPrefix: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'), + }); +} + +class AwsDynamoDbRunnerGroupCacheStore implements RunnerGroupCacheStore { + constructor(private readonly config: AwsDynamoDbRunnerGroupCacheStoreConfig) {} + + async get(runnerGroupName: string): Promise { + const id = this.itemId(runnerGroupName); + const result = await getDynamoDbClient().send( + new GetItemCommand({ + TableName: this.config.tableName, + Key: { + [ID_ATTRIBUTE]: { S: id }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': VALUE_ATTRIBUTE, + }, + }), + ); + + if (!result.Item) { + return undefined; + } + + const value = result.Item[VALUE_ATTRIBUTE]?.S; + if (value === undefined || !/^\d+$/.test(value)) { + throw new Error(`Runner group cache item '${id}' has an invalid value`); + } + + const runnerGroupId = Number(value); + if (!Number.isSafeInteger(runnerGroupId)) { + throw new Error(`Runner group cache item '${id}' has an invalid value`); + } + + return runnerGroupId; + } + + async create(record: RunnerGroupCacheRecord): Promise { + await getDynamoDbClient().send( + new PutItemCommand({ + TableName: this.config.tableName, + Item: { + [ID_ATTRIBUTE]: { S: this.itemId(record.runnerGroupName) }, + [VALUE_ATTRIBUTE]: { S: record.runnerGroupId.toString() }, + }, + ConditionExpression: 'attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#id': ID_ATTRIBUTE, + }, + }), + ); + } + + private itemId(runnerGroupName: string): string { + return `${this.config.configKeyPrefix}${RUNNER_GROUP_KEY}${runnerGroupName}`; + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts new file mode 100644 index 0000000000..a8960e3f56 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts @@ -0,0 +1,94 @@ +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerMatcherConfigStore } from './runner-matcher-config-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner matcher config store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX = 'config#'; + }); + + it('gets the matcher config with a strongly consistent projected read', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '[{"id":"runner"}]' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).resolves.toBe('[{"id":"runner"}]'); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + id: { S: 'config#runner-matcher-config' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': 'value', + }, + }); + }); + + it('returns an empty stored string for validation by the webhook config loader', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).resolves.toBe(''); + }); + + it('rejects a missing matcher config item', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).rejects.toThrow( + "Runner matcher config item 'config#runner-matcher-config' was not found", + ); + }); + + it('rejects a matcher config item without a string value', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { N: '1' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).rejects.toThrow( + "Runner matcher config item 'config#runner-matcher-config' does not contain a string value", + ); + }); + + it.each(['RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'] as const)( + 'rejects a missing or blank %s', + (name) => { + delete process.env[name]; + expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow(`Environment variable ${name} is not set`); + + process.env[name] = ' '; + expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }, + ); + + it('propagates DynamoDB read errors', async () => { + const error = new Error('read failed'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).rejects.toBe(error); + }); + + it('reuses the memoised client across reads', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '[]' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await store.get(); + await store.get(); + + expect(mockDynamoDbClient.commandCalls(GetItemCommand)).toHaveLength(2); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts new file mode 100644 index 0000000000..dce975b5de --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts @@ -0,0 +1,53 @@ +import { GetItemCommand } from '@aws-sdk/client-dynamodb'; + +import type { RunnerMatcherConfigStore } from '../../core'; +import { getDynamoDbClient } from './client'; +import { requiredEnvironmentValue } from './environment'; + +const ID_ATTRIBUTE = 'id'; +const VALUE_ATTRIBUTE = 'value'; +const RUNNER_MATCHER_CONFIG_KEY = 'runner-matcher-config'; + +interface AwsDynamoDbRunnerMatcherConfigStoreConfig { + tableName: string; + configKeyPrefix: string; +} + +export function createAwsDynamoDbRunnerMatcherConfigStore(): RunnerMatcherConfigStore { + return new AwsDynamoDbRunnerMatcherConfigStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'), + configKeyPrefix: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'), + }); +} + +class AwsDynamoDbRunnerMatcherConfigStore implements RunnerMatcherConfigStore { + constructor(private readonly config: AwsDynamoDbRunnerMatcherConfigStoreConfig) {} + + async get(): Promise { + const id = `${this.config.configKeyPrefix}${RUNNER_MATCHER_CONFIG_KEY}`; + const result = await getDynamoDbClient().send( + new GetItemCommand({ + TableName: this.config.tableName, + Key: { + [ID_ATTRIBUTE]: { S: id }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': VALUE_ATTRIBUTE, + }, + }), + ); + + if (!result.Item) { + throw new Error(`Runner matcher config item '${id}' was not found`); + } + + const value = result.Item[VALUE_ATTRIBUTE]?.S; + if (value === undefined) { + throw new Error(`Runner matcher config item '${id}' does not contain a string value`); + } + + return value; + } +} diff --git a/lambdas/libs/storage-providers/github-app-credentials.test.ts b/lambdas/libs/storage-providers/github-app-credentials.test.ts index fe1870e387..f043f789ad 100644 --- a/lambdas/libs/storage-providers/github-app-credentials.test.ts +++ b/lambdas/libs/storage-providers/github-app-credentials.test.ts @@ -11,7 +11,7 @@ vi.mock('./aws/ssm/github-app-credentials-store', () => ({ const createAwsSsmGitHubAppCredentialsStoreMock = vi.mocked(createAwsSsmGitHubAppCredentialsStore); const cleanEnv = process.env; -describe('GitHub App credentials store selection', () => { +describe('GitHub App credentials store', () => { beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; @@ -19,35 +19,22 @@ describe('GitHub App credentials store selection', () => { resetGitHubAppCredentialsStore(); }); - it.each([undefined, '', ' '])('uses aws_ssm for default selector input %j', (provider) => { - setProvider(provider); - const store = stubStore(); - - expect(getGitHubAppCredentialsStore()).toBe(store); - expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); - }); - - it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; - const store = stubStore(); + it.each([undefined, 'aws_ssm', 'aws_dynamodb', 'not-registered'])( + 'remains on aws_ssm for runner config provider input %j', + (provider) => { + setProvider(provider); + const store = stubStore(); - expect(getGitHubAppCredentialsStore()).toBe(store); - expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); - }); - - it('rejects an unsupported provider on first use', () => { - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; - - expect(() => getGitHubAppCredentialsStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); - expect(createAwsSsmGitHubAppCredentialsStoreMock).not.toHaveBeenCalled(); - }); + expect(getGitHubAppCredentialsStore()).toBe(store); + expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); + }, + ); - it('selects lazily and caches the created store', () => { + it('creates the store lazily and caches it', () => { const store = stubStore(); expect(createAwsSsmGitHubAppCredentialsStoreMock).not.toHaveBeenCalled(); const first = getGitHubAppCredentialsStore(); - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; const second = getGitHubAppCredentialsStore(); expect(first).toBe(store); diff --git a/lambdas/libs/storage-providers/github-app-credentials.ts b/lambdas/libs/storage-providers/github-app-credentials.ts index 683ab6bb3e..c6189bb604 100644 --- a/lambdas/libs/storage-providers/github-app-credentials.ts +++ b/lambdas/libs/storage-providers/github-app-credentials.ts @@ -1,19 +1,11 @@ import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; import type { GitHubAppCredentialsStore } from './core'; -import type {} from './environment'; -import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; - -type GitHubAppCredentialsStoreFactory = () => GitHubAppCredentialsStore; - -const providerFactories = { - aws_ssm: createAwsSsmGitHubAppCredentialsStore, -} as const satisfies Record; let githubAppCredentialsStore: GitHubAppCredentialsStore | undefined; export function getGitHubAppCredentialsStore(): GitHubAppCredentialsStore { - githubAppCredentialsStore ??= - providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + // GitHub App credentials remain in SSM independently of runner config storage selection. + githubAppCredentialsStore ??= createAwsSsmGitHubAppCredentialsStore(); return githubAppCredentialsStore; } diff --git a/lambdas/libs/storage-providers/github-webhook-secret.test.ts b/lambdas/libs/storage-providers/github-webhook-secret.test.ts index 5888e735b0..b4b742733a 100644 --- a/lambdas/libs/storage-providers/github-webhook-secret.test.ts +++ b/lambdas/libs/storage-providers/github-webhook-secret.test.ts @@ -11,7 +11,7 @@ vi.mock('./aws/ssm/github-webhook-secret-store', () => ({ const createAwsSsmGitHubWebhookSecretStoreMock = vi.mocked(createAwsSsmGitHubWebhookSecretStore); const cleanEnv = process.env; -describe('GitHub webhook secret store selection', () => { +describe('GitHub webhook secret store', () => { beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; @@ -19,35 +19,22 @@ describe('GitHub webhook secret store selection', () => { resetGitHubWebhookSecretStore(); }); - it.each([undefined, '', ' '])('uses aws_ssm for default selector input %j', (provider) => { - setProvider(provider); - const store = stubStore(); - - expect(getGitHubWebhookSecretStore()).toBe(store); - expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); - }); - - it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; - const store = stubStore(); + it.each([undefined, 'aws_ssm', 'aws_dynamodb', 'not-registered'])( + 'remains on aws_ssm for runner config provider input %j', + (provider) => { + setProvider(provider); + const store = stubStore(); - expect(getGitHubWebhookSecretStore()).toBe(store); - expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); - }); - - it('rejects an unsupported provider on first use', () => { - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; - - expect(() => getGitHubWebhookSecretStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); - expect(createAwsSsmGitHubWebhookSecretStoreMock).not.toHaveBeenCalled(); - }); + expect(getGitHubWebhookSecretStore()).toBe(store); + expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); + }, + ); - it('selects lazily and caches the created store', () => { + it('creates the store lazily and caches it', () => { const store = stubStore(); expect(createAwsSsmGitHubWebhookSecretStoreMock).not.toHaveBeenCalled(); const first = getGitHubWebhookSecretStore(); - process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; const second = getGitHubWebhookSecretStore(); expect(first).toBe(store); diff --git a/lambdas/libs/storage-providers/github-webhook-secret.ts b/lambdas/libs/storage-providers/github-webhook-secret.ts index f13df08718..f6abfc5349 100644 --- a/lambdas/libs/storage-providers/github-webhook-secret.ts +++ b/lambdas/libs/storage-providers/github-webhook-secret.ts @@ -1,19 +1,11 @@ import { createAwsSsmGitHubWebhookSecretStore } from './aws/ssm/github-webhook-secret-store'; import type { GitHubWebhookSecretStore } from './core'; -import type {} from './environment'; -import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; - -type GitHubWebhookSecretStoreFactory = () => GitHubWebhookSecretStore; - -const providerFactories = { - aws_ssm: createAwsSsmGitHubWebhookSecretStore, -} as const satisfies Record; let githubWebhookSecretStore: GitHubWebhookSecretStore | undefined; export function getGitHubWebhookSecretStore(): GitHubWebhookSecretStore { - githubWebhookSecretStore ??= - providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); + // The webhook secret remains in SSM independently of runner config storage selection. + githubWebhookSecretStore ??= createAwsSsmGitHubWebhookSecretStore(); return githubWebhookSecretStore; } diff --git a/lambdas/libs/storage-providers/package.json b/lambdas/libs/storage-providers/package.json index 745d026851..798cc52e32 100644 --- a/lambdas/libs/storage-providers/package.json +++ b/lambdas/libs/storage-providers/package.json @@ -22,6 +22,7 @@ "dependencies": { "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", + "@aws-sdk/client-dynamodb": "^3.1009.0", "@aws-sdk/client-ssm": "^3.1009.0" }, "nx": { diff --git a/lambdas/libs/storage-providers/provider.ts b/lambdas/libs/storage-providers/provider.ts index 82b2c7895b..3a7d546aeb 100644 --- a/lambdas/libs/storage-providers/provider.ts +++ b/lambdas/libs/storage-providers/provider.ts @@ -1,4 +1,4 @@ -export const runnerConfigStorageProviders = ['aws_ssm'] as const; +export const runnerConfigStorageProviders = ['aws_ssm', 'aws_dynamodb'] as const; export type RunnerConfigStorageProvider = (typeof runnerConfigStorageProviders)[number]; diff --git a/lambdas/libs/storage-providers/runner-config.test.ts b/lambdas/libs/storage-providers/runner-config.test.ts index 95ba3787dd..0c85093fa0 100644 --- a/lambdas/libs/storage-providers/runner-config.test.ts +++ b/lambdas/libs/storage-providers/runner-config.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAwsDynamoDbRunnerConfigStore } from './aws/dynamodb/runner-config-store'; import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; import type { RunnerConfigStore } from './core'; import { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; +vi.mock('./aws/dynamodb/runner-config-store', () => ({ + createAwsDynamoDbRunnerConfigStore: vi.fn(), +})); vi.mock('./aws/ssm/runner-config-store', () => ({ createAwsSsmRunnerConfigStore: vi.fn(), })); +const createAwsDynamoDbRunnerConfigStoreMock = vi.mocked(createAwsDynamoDbRunnerConfigStore); const createAwsSsmRunnerConfigStoreMock = vi.mocked(createAwsSsmRunnerConfigStore); const cleanEnv = process.env; @@ -25,6 +30,7 @@ describe('runner config store selection', () => { expect(getRunnerConfigStore()).toBe(store); expect(createAwsSsmRunnerConfigStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbRunnerConfigStoreMock).not.toHaveBeenCalled(); }); it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { @@ -33,14 +39,26 @@ describe('runner config store selection', () => { expect(getRunnerConfigStore()).toBe(store); expect(createAwsSsmRunnerConfigStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbRunnerConfigStoreMock).not.toHaveBeenCalled(); + }); + + it.each(['aws_dynamodb', ' AWS_DYNAMODB '])('uses aws_dynamodb for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubDynamoDbStore(); + + expect(getRunnerConfigStore()).toBe(store); + expect(createAwsDynamoDbRunnerConfigStoreMock).toHaveBeenCalledOnce(); + expect(createAwsSsmRunnerConfigStoreMock).not.toHaveBeenCalled(); }); it('rejects an unsupported provider on first use', () => { process.env.RUNNER_CONFIG_STORAGE_PROVIDER = 'not-registered'; expect(createAwsSsmRunnerConfigStoreMock).not.toHaveBeenCalled(); + expect(createAwsDynamoDbRunnerConfigStoreMock).not.toHaveBeenCalled(); expect(() => getRunnerConfigStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); expect(createAwsSsmRunnerConfigStoreMock).not.toHaveBeenCalled(); + expect(createAwsDynamoDbRunnerConfigStoreMock).not.toHaveBeenCalled(); }); it('selects lazily and caches the created store', () => { @@ -82,3 +100,9 @@ function stubStore(): RunnerConfigStore { createAwsSsmRunnerConfigStoreMock.mockReturnValue(store); return store; } + +function stubDynamoDbStore(): RunnerConfigStore { + const store = { create: vi.fn(), houseKeeper: vi.fn() } satisfies RunnerConfigStore; + createAwsDynamoDbRunnerConfigStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/runner-config.ts b/lambdas/libs/storage-providers/runner-config.ts index 180c808d78..d4d3998d15 100644 --- a/lambdas/libs/storage-providers/runner-config.ts +++ b/lambdas/libs/storage-providers/runner-config.ts @@ -1,3 +1,4 @@ +import { createAwsDynamoDbRunnerConfigStore } from './aws/dynamodb/runner-config-store'; import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; import type { RunnerConfigStore } from './core'; import type {} from './environment'; @@ -7,6 +8,7 @@ type RunnerConfigStoreFactory = () => RunnerConfigStore; const providerFactories = { aws_ssm: createAwsSsmRunnerConfigStore, + aws_dynamodb: createAwsDynamoDbRunnerConfigStore, } as const satisfies Record; let runnerConfigStore: RunnerConfigStore | undefined; diff --git a/lambdas/libs/storage-providers/runner-group-cache.test.ts b/lambdas/libs/storage-providers/runner-group-cache.test.ts index e67e307905..029e33be8a 100644 --- a/lambdas/libs/storage-providers/runner-group-cache.test.ts +++ b/lambdas/libs/storage-providers/runner-group-cache.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAwsDynamoDbRunnerGroupCacheStore } from './aws/dynamodb/runner-group-cache-store'; import { createAwsSsmRunnerGroupCacheStore } from './aws/ssm/runner-group-cache-store'; import type { RunnerGroupCacheStore } from './core'; import { getRunnerGroupCacheStore, resetRunnerGroupCacheStore } from './runner-group-cache'; +vi.mock('./aws/dynamodb/runner-group-cache-store', () => ({ + createAwsDynamoDbRunnerGroupCacheStore: vi.fn(), +})); vi.mock('./aws/ssm/runner-group-cache-store', () => ({ createAwsSsmRunnerGroupCacheStore: vi.fn(), })); +const createAwsDynamoDbRunnerGroupCacheStoreMock = vi.mocked(createAwsDynamoDbRunnerGroupCacheStore); const createAwsSsmRunnerGroupCacheStoreMock = vi.mocked(createAwsSsmRunnerGroupCacheStore); const cleanEnv = process.env; @@ -25,6 +30,7 @@ describe('runner group cache store selection', () => { expect(getRunnerGroupCacheStore()).toBe(store); expect(createAwsSsmRunnerGroupCacheStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbRunnerGroupCacheStoreMock).not.toHaveBeenCalled(); }); it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { @@ -33,6 +39,16 @@ describe('runner group cache store selection', () => { expect(getRunnerGroupCacheStore()).toBe(store); expect(createAwsSsmRunnerGroupCacheStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbRunnerGroupCacheStoreMock).not.toHaveBeenCalled(); + }); + + it.each(['aws_dynamodb', ' AWS_DYNAMODB '])('uses aws_dynamodb for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubDynamoDbStore(); + + expect(getRunnerGroupCacheStore()).toBe(store); + expect(createAwsDynamoDbRunnerGroupCacheStoreMock).toHaveBeenCalledOnce(); + expect(createAwsSsmRunnerGroupCacheStoreMock).not.toHaveBeenCalled(); }); it('rejects an unsupported provider on first use', () => { @@ -40,6 +56,7 @@ describe('runner group cache store selection', () => { expect(() => getRunnerGroupCacheStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); expect(createAwsSsmRunnerGroupCacheStoreMock).not.toHaveBeenCalled(); + expect(createAwsDynamoDbRunnerGroupCacheStoreMock).not.toHaveBeenCalled(); }); it('selects lazily and caches the created store', () => { @@ -81,3 +98,9 @@ function stubStore(): RunnerGroupCacheStore { createAwsSsmRunnerGroupCacheStoreMock.mockReturnValue(store); return store; } + +function stubDynamoDbStore(): RunnerGroupCacheStore { + const store = { get: vi.fn(), create: vi.fn() } satisfies RunnerGroupCacheStore; + createAwsDynamoDbRunnerGroupCacheStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/runner-group-cache.ts b/lambdas/libs/storage-providers/runner-group-cache.ts index a28b00f48e..3a91437755 100644 --- a/lambdas/libs/storage-providers/runner-group-cache.ts +++ b/lambdas/libs/storage-providers/runner-group-cache.ts @@ -1,3 +1,4 @@ +import { createAwsDynamoDbRunnerGroupCacheStore } from './aws/dynamodb/runner-group-cache-store'; import { createAwsSsmRunnerGroupCacheStore } from './aws/ssm/runner-group-cache-store'; import type { RunnerGroupCacheStore } from './core'; import type {} from './environment'; @@ -7,6 +8,7 @@ type RunnerGroupCacheStoreFactory = () => RunnerGroupCacheStore; const providerFactories = { aws_ssm: createAwsSsmRunnerGroupCacheStore, + aws_dynamodb: createAwsDynamoDbRunnerGroupCacheStore, } as const satisfies Record; let runnerGroupCacheStore: RunnerGroupCacheStore | undefined; diff --git a/lambdas/libs/storage-providers/runner-matcher-config.test.ts b/lambdas/libs/storage-providers/runner-matcher-config.test.ts index 0dd7f42ad3..981ff28567 100644 --- a/lambdas/libs/storage-providers/runner-matcher-config.test.ts +++ b/lambdas/libs/storage-providers/runner-matcher-config.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAwsDynamoDbRunnerMatcherConfigStore } from './aws/dynamodb/runner-matcher-config-store'; import { createAwsSsmRunnerMatcherConfigStore } from './aws/ssm/runner-matcher-config-store'; import type { RunnerMatcherConfigStore } from './core'; import { getRunnerMatcherConfigStore, resetRunnerMatcherConfigStore } from './runner-matcher-config'; +vi.mock('./aws/dynamodb/runner-matcher-config-store', () => ({ + createAwsDynamoDbRunnerMatcherConfigStore: vi.fn(), +})); vi.mock('./aws/ssm/runner-matcher-config-store', () => ({ createAwsSsmRunnerMatcherConfigStore: vi.fn(), })); +const createAwsDynamoDbRunnerMatcherConfigStoreMock = vi.mocked(createAwsDynamoDbRunnerMatcherConfigStore); const createAwsSsmRunnerMatcherConfigStoreMock = vi.mocked(createAwsSsmRunnerMatcherConfigStore); const cleanEnv = process.env; @@ -25,6 +30,7 @@ describe('runner matcher config store selection', () => { expect(getRunnerMatcherConfigStore()).toBe(store); expect(createAwsSsmRunnerMatcherConfigStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbRunnerMatcherConfigStoreMock).not.toHaveBeenCalled(); }); it.each(['aws_ssm', ' AWS_SSM '])('uses aws_ssm for explicit selector input %j', (provider) => { @@ -33,6 +39,16 @@ describe('runner matcher config store selection', () => { expect(getRunnerMatcherConfigStore()).toBe(store); expect(createAwsSsmRunnerMatcherConfigStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbRunnerMatcherConfigStoreMock).not.toHaveBeenCalled(); + }); + + it.each(['aws_dynamodb', ' AWS_DYNAMODB '])('uses aws_dynamodb for explicit selector input %j', (provider) => { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + const store = stubDynamoDbStore(); + + expect(getRunnerMatcherConfigStore()).toBe(store); + expect(createAwsDynamoDbRunnerMatcherConfigStoreMock).toHaveBeenCalledOnce(); + expect(createAwsSsmRunnerMatcherConfigStoreMock).not.toHaveBeenCalled(); }); it('rejects an unsupported provider on first use', () => { @@ -40,6 +56,7 @@ describe('runner matcher config store selection', () => { expect(() => getRunnerMatcherConfigStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); expect(createAwsSsmRunnerMatcherConfigStoreMock).not.toHaveBeenCalled(); + expect(createAwsDynamoDbRunnerMatcherConfigStoreMock).not.toHaveBeenCalled(); }); it('selects lazily and caches the created store', () => { @@ -81,3 +98,9 @@ function stubStore(): RunnerMatcherConfigStore { createAwsSsmRunnerMatcherConfigStoreMock.mockReturnValue(store); return store; } + +function stubDynamoDbStore(): RunnerMatcherConfigStore { + const store = { get: vi.fn() } satisfies RunnerMatcherConfigStore; + createAwsDynamoDbRunnerMatcherConfigStoreMock.mockReturnValue(store); + return store; +} diff --git a/lambdas/libs/storage-providers/runner-matcher-config.ts b/lambdas/libs/storage-providers/runner-matcher-config.ts index 6d56d49754..c121fc2aaf 100644 --- a/lambdas/libs/storage-providers/runner-matcher-config.ts +++ b/lambdas/libs/storage-providers/runner-matcher-config.ts @@ -1,3 +1,4 @@ +import { createAwsDynamoDbRunnerMatcherConfigStore } from './aws/dynamodb/runner-matcher-config-store'; import { createAwsSsmRunnerMatcherConfigStore } from './aws/ssm/runner-matcher-config-store'; import type { RunnerMatcherConfigStore } from './core'; import type {} from './environment'; @@ -7,6 +8,7 @@ type RunnerMatcherConfigStoreFactory = () => RunnerMatcherConfigStore; const providerFactories = { aws_ssm: createAwsSsmRunnerMatcherConfigStore, + aws_dynamodb: createAwsDynamoDbRunnerMatcherConfigStore, } as const satisfies Record; let runnerMatcherConfigStore: RunnerMatcherConfigStore | undefined; diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 1322072419..c80fecbd3a 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -215,6 +215,7 @@ __metadata: dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-sdk/client-dynamodb": "npm:^3.1009.0" "@aws-sdk/client-ssm": "npm:^3.1009.0" aws-sdk-client-mock: "npm:^4.1.0" aws-sdk-client-mock-jest: "npm:^4.1.0" @@ -354,6 +355,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-dynamodb@npm:^3.1009.0": + version: 3.1108.0 + resolution: "@aws-sdk/client-dynamodb@npm:3.1108.0" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/credential-provider-node": "npm:^3.972.79" + "@aws-sdk/dynamodb-codec": "npm:^3.973.42" + "@aws-sdk/middleware-endpoint-discovery": "npm:^3.972.28" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/fetch-http-handler": "npm:^5.6.13" + "@smithy/node-http-handler": "npm:^4.9.13" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/f72760e4967709a31b9d3b6615116ce1161aaed05ace32b92c8d916c809ca6a5aed61f9f4d3d3cf2ecbf00ca077bddbc00f6f5df88c7c2dc9392c19479551d66 + languageName: node + linkType: hard + "@aws-sdk/client-ec2@npm:^3.1009.0": version: 3.1014.0 resolution: "@aws-sdk/client-ec2@npm:3.1014.0" @@ -632,6 +651,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:^3.977.7": + version: 3.977.7 + resolution: "@aws-sdk/core@npm:3.977.7" + dependencies: + "@aws-sdk/types": "npm:^3.974.3" + "@aws-sdk/xml-builder": "npm:^3.972.38" + "@aws/lambda-invoke-store": "npm:^0.3.0" + "@smithy/core": "npm:^3.31.1" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.16.1" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/305bc5d7bd61b33bbdbec7abc5dbf03fb64ea8e2b60fcda9e4ab22ce2fc09eea6db71769010ead7437e75d09c6407acedfe1f8a52e9b1ea97ba5c0ebd3e348e0 + languageName: node + linkType: hard + "@aws-sdk/crc64-nvme@npm:^3.972.5": version: 3.972.5 resolution: "@aws-sdk/crc64-nvme@npm:3.972.5" @@ -655,6 +690,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:^3.972.68": + version: 3.972.68 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.68" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/31b9d5fd71d00556ecf1bc5da793e8cb04f98d09ccb00c8b65a59b88a6a860a7737e1aeefcb5f7e62f322f9f3a7eba279b205c8623e912d936fe01a667ab724a + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-http@npm:3.972.23" @@ -673,6 +721,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:^3.972.70": + version: 3.972.70 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.70" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/fetch-http-handler": "npm:^5.6.13" + "@smithy/node-http-handler": "npm:^4.9.13" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/9230d722c0307bbafd0b56a42c25708a25a0d087c9993c37f628fbae8e142120d73a34738b6b0bf15f57efdbcf664feb6ae773e2526b3729663ce54403393487 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-ini@npm:3.972.23" @@ -695,6 +758,27 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:^3.973.13": + version: 3.973.13 + resolution: "@aws-sdk/credential-provider-ini@npm:3.973.13" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/credential-provider-env": "npm:^3.972.68" + "@aws-sdk/credential-provider-http": "npm:^3.972.70" + "@aws-sdk/credential-provider-login": "npm:^3.972.75" + "@aws-sdk/credential-provider-process": "npm:^3.972.68" + "@aws-sdk/credential-provider-sso": "npm:^3.973.12" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.74" + "@aws-sdk/nested-clients": "npm:^3.997.42" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/0d7c76a3227f21e8c08af3db03e369651c9efb795b964707f243d69bdc1fb322b712a1be7e7d474ffaca25118eb00ce6eeb56a646850d72a14a8f7e6120cc02c + languageName: node + linkType: hard + "@aws-sdk/credential-provider-login@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-login@npm:3.972.23" @@ -711,6 +795,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-login@npm:^3.972.75": + version: 3.972.75 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.75" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/nested-clients": "npm:^3.997.42" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/ab05ddced3fe6bc9360b79950fb07ed01a06c103771c36686d3495135a18d55ebbc8b5fb56744b120e2cc2cc9e413615ce6e1b1c5186ca97b8eb68142162081e + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:^3.972.24": version: 3.972.24 resolution: "@aws-sdk/credential-provider-node@npm:3.972.24" @@ -731,6 +829,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:^3.972.79": + version: 3.972.79 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.79" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.68" + "@aws-sdk/credential-provider-http": "npm:^3.972.70" + "@aws-sdk/credential-provider-ini": "npm:^3.973.13" + "@aws-sdk/credential-provider-process": "npm:^3.972.68" + "@aws-sdk/credential-provider-sso": "npm:^3.973.12" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.74" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/0c7b282b9b8a6774e0acc987e1fff59d10926afe336b0dfe0fa4af84d333b46df50f8679b2cce95d73b75b0ae593b44ad4896bc8d54e337a3b611666cf1ceb6a + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:^3.972.21": version: 3.972.21 resolution: "@aws-sdk/credential-provider-process@npm:3.972.21" @@ -745,6 +862,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:^3.972.68": + version: 3.972.68 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.68" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/97a7061fdf997588ab8ea2feb4a5a268e3c94e841e3418faf683fade3bca1c627b6e30969666d7ca96207a37fe7178e704eb8adc720ac8eb391e8c1e85291f11 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-sso@npm:3.972.23" @@ -761,6 +891,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:^3.973.12": + version: 3.973.12 + resolution: "@aws-sdk/credential-provider-sso@npm:3.973.12" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/nested-clients": "npm:^3.997.42" + "@aws-sdk/token-providers": "npm:3.1108.0" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/061f0219ace75565abe79480f81920eb4bfe45e84b53901941c3b7d6c9aee7a67542e1058fbeb40139fd4887bb42cdf759ba1ebdb7b7cbb2fa9cd1e033e32871 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.23" @@ -776,6 +921,42 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:^3.972.74": + version: 3.972.74 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.74" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/nested-clients": "npm:^3.997.42" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/9c1da6479fdc329a7540420e0e5a1030f159f689150acb7d1d67ae9c91b99415b47f0abce859bc8987e9a7aee109e411e45f0e197b5e1d94c3c31379c8a426b2 + languageName: node + linkType: hard + +"@aws-sdk/dynamodb-codec@npm:^3.973.42": + version: 3.973.42 + resolution: "@aws-sdk/dynamodb-codec@npm:3.973.42" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/9e4489b2d90112aab0e98dde04d2e787f31ab1e06e4473ce85f7eff0851e9461720707fd2cd5ae6d48e70635b30c979066ea6bd3e396aaad37ad39e494be9690 + languageName: node + linkType: hard + +"@aws-sdk/endpoint-cache@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/endpoint-cache@npm:3.972.10" + dependencies: + mnemonist: "npm:0.38.3" + tslib: "npm:^2.6.2" + checksum: 10c0/cdcad9be7fe0c6ace273ddeaa145f397653d2866fff5a3620410d389f45b84bf2856269c9c404f082d52d1a9849b83608f809582b7689cf35abd4affdf7af97d + languageName: node + linkType: hard + "@aws-sdk/lib-storage@npm:^3.1009.0": version: 3.1014.0 resolution: "@aws-sdk/lib-storage@npm:3.1014.0" @@ -808,6 +989,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-endpoint-discovery@npm:^3.972.28": + version: 3.972.28 + resolution: "@aws-sdk/middleware-endpoint-discovery@npm:3.972.28" + dependencies: + "@aws-sdk/endpoint-cache": "npm:^3.972.10" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/6bcbc9b3f28664d075997ed4b1fe3a1725e45f9d7c00265d08d022cb0acc7a00519cc5e922d50f8e9fcd1eb5d2158a888d46a6c2c6e78a7832cf54d0c902b478 + languageName: node + linkType: hard + "@aws-sdk/middleware-expect-continue@npm:^3.972.8": version: 3.972.8 resolution: "@aws-sdk/middleware-expect-continue@npm:3.972.8" @@ -1014,6 +1208,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/nested-clients@npm:^3.997.42": + version: 3.997.42 + resolution: "@aws-sdk/nested-clients@npm:3.997.42" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.44" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/fetch-http-handler": "npm:^5.6.13" + "@smithy/node-http-handler": "npm:^4.9.13" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/1405a73a86aa904fa503592fcc0793dc5b8ab994d1c9eda1b017ca26bd1a659fd87cd9af762c713466ebc8950ced06ee49521a5dea25a99edbe011720e1d86e3 + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:^3.972.9": version: 3.972.9 resolution: "@aws-sdk/region-config-resolver@npm:3.972.9" @@ -1041,6 +1251,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/signature-v4-multi-region@npm:^3.996.44": + version: 3.996.44 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.44" + dependencies: + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b9c970abad58f9f87dcb2577f9121c7c9015ea18b4101e4813ff92bb5c19524c8634f6507f3c273ad8694d5ef6f272d5bd0648993c16431fcb09dcc402dd7d72 + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.1014.0": version: 3.1014.0 resolution: "@aws-sdk/token-providers@npm:3.1014.0" @@ -1056,6 +1278,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.1108.0": + version: 3.1108.0 + resolution: "@aws-sdk/token-providers@npm:3.1108.0" + dependencies: + "@aws-sdk/core": "npm:^3.977.7" + "@aws-sdk/nested-clients": "npm:^3.997.42" + "@aws-sdk/types": "npm:^3.974.3" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/4593b1228d55cfc960cb22c6eecfe29032f4b1a01f0452f46ba22cdaa7bcde051f5749cfcc465ef682f659cc7a4f0d7c55ce254158b88eacf3ca1c3fa7946e7e + languageName: node + linkType: hard + "@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.4.1, @aws-sdk/types@npm:^3.973.6": version: 3.973.6 resolution: "@aws-sdk/types@npm:3.973.6" @@ -1066,6 +1302,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:^3.974.3": + version: 3.974.3 + resolution: "@aws-sdk/types@npm:3.974.3" + dependencies: + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/6850403d8d9358ea497a63eaa50129f1989a2acc959878bb473525f04238099ab85f9bb7877f9d03191f3397941cf841ce80aaf0f319c94273e41fedf51661d7 + languageName: node + linkType: hard + "@aws-sdk/util-arn-parser@npm:^3.972.3": version: 3.972.3 resolution: "@aws-sdk/util-arn-parser@npm:3.972.3" @@ -1151,6 +1397,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/xml-builder@npm:^3.972.38": + version: 3.972.38 + resolution: "@aws-sdk/xml-builder@npm:3.972.38" + dependencies: + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/8f4c500bea2e1060b2cf47aac4be87b80e9bdf5389d3d3aeaca66397ebc4b15cdc39a8d3f0d54df2c39597c3c2957edce4cca0d92a7a875aedcfdb094d6910ec + languageName: node + linkType: hard + "@aws/lambda-invoke-store@npm:0.2.3, @aws/lambda-invoke-store@npm:^0.2.2": version: 0.2.3 resolution: "@aws/lambda-invoke-store@npm:0.2.3" @@ -1158,6 +1414,13 @@ __metadata: languageName: node linkType: hard +"@aws/lambda-invoke-store@npm:^0.3.0": + version: 0.3.0 + resolution: "@aws/lambda-invoke-store@npm:0.3.0" + checksum: 10c0/b4a2e6b3b5397bc606053e64270d26dc5c886336f88a98cad587b1592eec17058f8fb172f1827a9f0e591f3595cf8f01575c8c9b36cde38c06456f8a65204046 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -4416,6 +4679,16 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.31.1, @smithy/core@npm:^3.32.0": + version: 3.32.0 + resolution: "@smithy/core@npm:3.32.0" + dependencies: + "@smithy/types": "npm:^4.17.0" + tslib: "npm:^2.6.2" + checksum: 10c0/2d3a973587715641bbe3f46b09337024f181d20cc0baddb402c42eb9aecd17bc92fbcc61f0b248ddcc75982266656fdeb3ca100509d6a3a412e6c54c5f61722e + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/credential-provider-imds@npm:4.2.12" @@ -4429,6 +4702,17 @@ __metadata: languageName: node linkType: hard +"@smithy/credential-provider-imds@npm:^4.4.16": + version: 4.5.0 + resolution: "@smithy/credential-provider-imds@npm:4.5.0" + dependencies: + "@smithy/core": "npm:^3.32.0" + "@smithy/types": "npm:^4.17.0" + tslib: "npm:^2.6.2" + checksum: 10c0/b639b737fe6a03f5c0bc8e95301a041f9ec3df9d2adc55e59a8079e73da0e8865511bb67f9f052e492a3413a84acd02b6ba1b34972e886c3311ee3bd77afbfdb + languageName: node + linkType: hard + "@smithy/eventstream-codec@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/eventstream-codec@npm:4.2.12" @@ -4497,6 +4781,17 @@ __metadata: languageName: node linkType: hard +"@smithy/fetch-http-handler@npm:^5.6.13": + version: 5.7.0 + resolution: "@smithy/fetch-http-handler@npm:5.7.0" + dependencies: + "@smithy/core": "npm:^3.32.0" + "@smithy/types": "npm:^4.17.0" + tslib: "npm:^2.6.2" + checksum: 10c0/2384d4f000855c8f1b097f959136820da1f133bf5c73883d2365c295776b0142f5bc3660721ee66e99d2c6a1ccce93158ee2c42f5983cf9f00d86ccab8c5dd0b + languageName: node + linkType: hard + "@smithy/hash-blob-browser@npm:^4.2.13": version: 4.2.13 resolution: "@smithy/hash-blob-browser@npm:4.2.13" @@ -4662,6 +4957,17 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^4.9.13": + version: 4.10.0 + resolution: "@smithy/node-http-handler@npm:4.10.0" + dependencies: + "@smithy/core": "npm:^3.32.0" + "@smithy/types": "npm:^4.17.0" + tslib: "npm:^2.6.2" + checksum: 10c0/6cf7e09b943c6b291aa7abaf5db1711643a1b25fe36eacc8fbfd2225616be26b22e73a42dd90a9b953836bf1503ae9a2d24d601bf1d97d49b00e582f1dd81a7d + languageName: node + linkType: hard + "@smithy/property-provider@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/property-provider@npm:4.2.12" @@ -4747,6 +5053,17 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^5.6.12": + version: 5.7.0 + resolution: "@smithy/signature-v4@npm:5.7.0" + dependencies: + "@smithy/core": "npm:^3.32.0" + "@smithy/types": "npm:^4.17.0" + tslib: "npm:^2.6.2" + checksum: 10c0/4ac74ae7f1e6f40ca153ae2adb185754871c8696bb176a0b759e275b1bc634b4d82f2e85d6bad12a1ec8efb0f26c424328f9e39ce58a5ac6c2ba7a72ed992128 + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^4.12.7": version: 4.12.7 resolution: "@smithy/smithy-client@npm:4.12.7" @@ -4780,6 +5097,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.16.1, @smithy/types@npm:^4.17.0": + version: 4.17.0 + resolution: "@smithy/types@npm:4.17.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/f985f116e02ad60168a4bcd97140e971ee0a83a083574a8800d3364c62c2445d1fa2314214f19d16446acda18bed9f0ee632cacdd897804c51339a5a1c9ce422 + languageName: node + linkType: hard + "@smithy/url-parser@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/url-parser@npm:4.2.12" @@ -8898,6 +9224,15 @@ __metadata: languageName: node linkType: hard +"mnemonist@npm:0.38.3": + version: 0.38.3 + resolution: "mnemonist@npm:0.38.3" + dependencies: + obliterator: "npm:^1.6.1" + checksum: 10c0/064aa1ee1a89fce2754423b3617c598fd65bc34311eb3c01dc063976f6b819b073bd23532415cf8c92240157b4c8fbb7ec5d79d717f2bd4fcd95d8131cb23acb + languageName: node + linkType: hard + "moment-timezone@npm:^0.6.0": version: 0.6.0 resolution: "moment-timezone@npm:0.6.0" @@ -9300,6 +9635,13 @@ __metadata: languageName: node linkType: hard +"obliterator@npm:^1.6.1": + version: 1.6.1 + resolution: "obliterator@npm:1.6.1" + checksum: 10c0/5fad57319aae0ef6e34efa640541d41c2dd9790a7ab808f17dcb66c83a81333963fc2dfcfa6e1b62158e5cef6291cdcf15c503ad6c3de54b2227dd4c3d7e1b55 + languageName: node + linkType: hard + "obug@npm:^2.1.1": version: 2.1.1 resolution: "obug@npm:2.1.1" From f95eec493e5e272e938efeeca0696e337fe8fb89 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 14:53:54 +0200 Subject: [PATCH 46/49] feat(storage): track shared runner state in DynamoDB --- .../src/pool/pool-contract.test.ts | 4 + .../control-plane/src/pool/pool.test.ts | 88 +++ .../functions/control-plane/src/pool/pool.ts | 63 +- .../src/scale-runners/github-runner.ts | 192 +++++- .../src/scale-runners/job-retry.test.ts | 7 + .../scale-runners/scale-down-contract.test.ts | 4 + .../src/scale-runners/scale-down.test.ts | 353 +++++++++++ .../src/scale-runners/scale-down.ts | 388 ++++++++++-- .../scale-runners/scale-up-contract.test.ts | 6 + .../src/scale-runners/scale-up.test.ts | 303 +++++++++- .../src/scale-runners/scale-up.ts | 34 +- .../compute-provider-contracts/scale-up.ts | 4 +- .../ec2/src/control-plane/runner-config.ts | 3 + .../ec2/src/control-plane/scale-up.test.ts | 27 +- .../aws/ec2/src/control-plane/scale-up.ts | 6 +- .../aws/ec2/src/environment.d.ts | 1 + lambdas/libs/compute-providers/core/index.ts | 9 +- .../aws/dynamodb/durable-config.ts | 37 ++ .../aws/dynamodb/environment.d.ts | 6 +- .../aws/dynamodb/environment.ts | 6 +- .../github-app-credentials-store.test.ts | 103 ++++ .../dynamodb/github-app-credentials-store.ts | 89 +++ .../github-webhook-secret-store.test.ts | 74 +++ .../dynamodb/github-webhook-secret-store.ts | 21 + .../aws/dynamodb/keys.test.ts | 50 ++ .../storage-providers/aws/dynamodb/keys.ts | 34 ++ .../aws/dynamodb/runner-config-store.test.ts | 71 ++- .../aws/dynamodb/runner-config-store.ts | 18 +- .../dynamodb/runner-group-cache-store.test.ts | 15 +- .../aws/dynamodb/runner-group-cache-store.ts | 32 +- .../runner-matcher-config-store.test.ts | 31 +- .../dynamodb/runner-matcher-config-store.ts | 40 +- .../aws/dynamodb/runner-state-store.test.ts | 451 ++++++++++++++ .../aws/dynamodb/runner-state-store.ts | 556 ++++++++++++++++++ lambdas/libs/storage-providers/core/index.ts | 51 ++ .../github-app-credentials.test.ts | 67 ++- .../github-app-credentials.ts | 14 +- .../github-webhook-secret.test.ts | 67 ++- .../github-webhook-secret.ts | 14 +- lambdas/libs/storage-providers/index.ts | 9 + .../storage-providers/runner-state.test.ts | 89 +++ .../libs/storage-providers/runner-state.ts | 25 + .../libs/storage-providers/vitest.config.ts | 1 + 43 files changed, 3241 insertions(+), 222 deletions(-) create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/keys.test.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/keys.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.test.ts create mode 100644 lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.ts create mode 100644 lambdas/libs/storage-providers/runner-state.test.ts create mode 100644 lambdas/libs/storage-providers/runner-state.ts 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 a949afae39..9e77b56899 100644 --- a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts @@ -17,6 +17,10 @@ vi.mock('../github/auth', () => ({ getStoredInstallationId: vi.fn().mockResolvedValue(undefined), })); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getRunnerStateStore: vi.fn().mockReturnValue(undefined), +})); + vi.mock('../scale-runners/github-runner', () => ({ createStartRunnerConfig: vi.fn(), getGitHubEnterpriseApiUrl: vi.fn(), diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index a4bdca3000..9202846a98 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -4,6 +4,11 @@ import * as nock from 'nock'; import { createRunners } from '@aws-github-runner/compute-providers/aws/ec2/control-plane/runner-config'; import { listEC2Runners } from '@aws-github-runner/compute-providers/aws/ec2/control-plane/runners'; +import { + getRunnerStateStore, + type RunnerStateRecord, + type RunnerStateStore, +} from '@aws-github-runner/storage-providers'; import * as ghAuth from '../github/auth'; import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner'; import { adjust } from './pool'; @@ -45,6 +50,10 @@ vi.mock('@aws-github-runner/compute-providers/aws/ec2/control-plane/runner-confi createRunners: vi.fn(), })); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getRunnerStateStore: vi.fn(), +})); + vi.mock('../scale-runners/github-runner', async () => ({ createStartRunnerConfig: vi.fn(), getGitHubEnterpriseApiUrl: vi.fn().mockReturnValue({ @@ -58,6 +67,18 @@ const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); const mockedInstallationAuth = vi.mocked(ghAuth.createGithubInstallationAuth); const mockCreateClient = vi.mocked(ghAuth.createOctokitClient); const mockListRunners = vi.mocked(listEC2Runners); +const mockGetRunnerStateStore = vi.mocked(getRunnerStateStore); +const mockRunnerStateStore: RunnerStateStore = { + create: vi.fn(), + recordGitHubIdentity: vi.fn(), + activate: vi.fn(), + list: vi.fn(), + markOrphan: vi.fn(), + unmarkOrphan: vi.fn(), + beginTermination: vi.fn(), + cancelTermination: vi.fn(), + delete: vi.fn(), +}; const cleanEnv = process.env; @@ -159,6 +180,8 @@ beforeEach(() => { mockOctokit.paginate.mockImplementation(() => githubRunnersRegistered); mockListRunners.mockImplementation(async () => ec2InstancesRegistered); + mockGetRunnerStateStore.mockReturnValue(undefined); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([]); vi.mocked(createRunners).mockResolvedValue({ instances: [], retryableErrorCount: 0, @@ -570,3 +593,68 @@ describe('Test simple pool.', () => { }); }); }); + +describe('durable runner inventory', () => { + beforeEach(() => { + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + }); + + it('uses stored inventory for pool availability and maximum headroom', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '5'; + vi.mocked(mockRunnerStateStore.list).mockResolvedValue(ec2InstancesRegistered.map(runnerStateRecord)); + + await adjust({ poolSize: 10, type: 'ec2' }); + + expect(mockRunnerStateStore.list).toHaveBeenCalledWith({ computeProvider: 'ec2' }); + expect(createRunners).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + expect.anything(), + expect.anything(), + 'pool-lambda', + ); + }); + + it('includes provider-discovered resources missing from inventory as recovery', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '4'; + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([runnerStateRecord(ec2InstancesRegistered[0])]); + + await adjust({ poolSize: 10, type: 'ec2' }); + + expect(createRunners).not.toHaveBeenCalled(); + }); + + it('does not count orphan inventory records as available pool capacity', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '5'; + mockListRunners.mockResolvedValue([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([ + { ...runnerStateRecord(ec2InstancesRegistered[0]), state: 'orphan' }, + ]); + + await adjust({ poolSize: 1, type: 'ec2' }); + + expect(createRunners).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + expect.anything(), + expect.anything(), + 'pool-lambda', + ); + }); +}); + +function runnerStateRecord(runner: (typeof ec2InstancesRegistered)[number]): RunnerStateRecord { + const timestamp = runner.launchTime.toISOString(); + return { + runnerId: runner.id, + computeProvider: 'ec2', + computeResourceId: runner.id, + runnerOwner: ORG, + runnerType: 'Org', + state: 'active', + createdAt: timestamp, + updatedAt: timestamp, + }; +} diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 21e91adebc..66d75434e3 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -1,6 +1,7 @@ import { Octokit } from '@octokit/rest'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { getRunnerStateStore, type RunnerStateRecord } from '@aws-github-runner/storage-providers'; import yn from 'yn'; import { @@ -58,26 +59,43 @@ export async function adjust(event: PoolEvent): Promise { runnerNamePrefix, ); - // Look up the managed provider runners, but running does not mean idle. - const poolRunners = await computeProvider.listRunners({ + const runnerStateStore = getRunnerStateStore(); + let currentRunnerCount: number; + let numberOfRunnersInPool: number; + const providerRunners = await computeProvider.listRunners({ environment, runnerOwner, runnerType: 'Org', }); - - const numberOfRunnersInPool = computeProvider.countAvailableRunners(poolRunners, runnerStatusses, includeBusyRunners); + if (runnerStateStore) { + const runnerStates = (await runnerStateStore.list({ computeProvider: computeProvider.type })).filter( + (record) => record.runnerOwner === runnerOwner && record.runnerType === 'Org', + ); + const storedComputeResourceIds = new Set(runnerStates.map((record) => record.computeResourceId)); + const untrackedProviderRunners = providerRunners.filter((runner) => !storedComputeResourceIds.has(runner.id)); + // Inventory is canonical for tracked resources. Provider discovery contributes + // only untracked resources so a launch-before-state crash cannot over-provision. + currentRunnerCount = runnerStates.length + untrackedProviderRunners.length; + numberOfRunnersInPool = + countAvailableStoredRunners(runnerStates, runnerStatusses, includeBusyRunners) + + computeProvider.countAvailableRunners(untrackedProviderRunners, runnerStatusses, includeBusyRunners); + } else { + // Look up the managed provider runners, but running does not mean idle. + currentRunnerCount = providerRunners.length; + numberOfRunnersInPool = computeProvider.countAvailableRunners(providerRunners, runnerStatusses, includeBusyRunners); + } let topUp = event.poolSize - numberOfRunnersInPool; // The pool must never push the total number of runners (busy + idle) past the configured maximum. - // poolRunners contains every running runner for this type, so its length is the current total and no - // extra API call is needed. Without this clamp the pool keeps topping up against idle-only counts and - // can overshoot runners_maximum_count, while the scale-up lambda correctly refuses to launch. + // currentRunnerCount includes both canonical inventory and untracked provider recovery records. Without + // this clamp the pool keeps topping up against idle-only counts and can overshoot runners_maximum_count, + // while the scale-up lambda correctly refuses to launch. if (maximumRunners !== -1 && topUp > 0) { - const headroom = maximumRunners - poolRunners.length; + const headroom = maximumRunners - currentRunnerCount; if (topUp > headroom) { logger.info( `Capping pool top-up from ${topUp} to ${Math.max(headroom, 0)} to respect the maximum of ` + - `${maximumRunners} runners (currently ${poolRunners.length} running).`, + `${maximumRunners} runners (currently ${currentRunnerCount} running).`, ); topUp = headroom; } @@ -106,6 +124,33 @@ export async function adjust(event: PoolEvent): Promise { } } +function countAvailableStoredRunners( + runnerStates: RunnerStateRecord[], + runnerStatuses: Map, + includeBusyRunners: boolean, +): number { + let available = 0; + for (const runner of runnerStates) { + if (runner.state === 'orphan' || runner.state === 'terminating') { + continue; + } + + const status = runnerStatuses.get(runner.computeResourceId); + if ((status?.busy === false || includeBusyRunners) && status?.status === 'online') { + available++; + } else if (status === undefined && !runnerBootTimeExceeded(runner.createdAt)) { + available++; + } + } + return available; +} + +function runnerBootTimeExceeded(createdAt: string): boolean { + const bootTimeMinutes = Number(process.env.RUNNER_BOOT_TIME_IN_MINUTES); + const launchTime = new Date(createdAt).getTime(); + return launchTime + bootTimeMinutes * 60_000 < Date.now(); +} + async function getInstallationId(appToken: string, ghesApiUrl: string, org: string, appIndex: number): Promise { // Use the pre-configured installation ID when available (avoids an API call). const storedId = await getStoredInstallationId(appIndex); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index ec78a2a2b5..c47b0d09ee 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -2,9 +2,13 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { getRunnerGroupCacheStore, getRunnerConfigStore, + getRunnerStateStore, type RunnerConfigMetadata, + type RunnerConfigRecord, type RunnerConfigStore, + type RunnerStateStore, } from '@aws-github-runner/storage-providers'; +import type { ComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; import { Octokit } from '@octokit/rest'; import { getStoredInstallationId } from '../github/auth'; @@ -19,6 +23,8 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { + computeProvider?: ComputeProviderType; + getRunnerConfigAccessScope?: (runnerId: string) => string | undefined; getRunnerConfigMetadata?: (runnerId: string) => RunnerConfigMetadata[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; } @@ -216,10 +222,21 @@ export async function createStartRunnerConfig( options: StartRunnerConfigOptions = {}, ): Promise { const runnerConfigStore = getRunnerConfigStore(); + const runnerStateStore = getRunnerStateStore(); + if (runnerStateStore && options.computeProvider === undefined) { + throw new Error('A compute provider is required when runner state storage is enabled'); + } if (githubRunnerConfig.enableJitConfig && githubRunnerConfig.ephemeral) { - return await createJitConfig(githubRunnerConfig, runnerIds, ghClient, runnerConfigStore, options); + return await createJitConfig(githubRunnerConfig, runnerIds, ghClient, runnerConfigStore, runnerStateStore, options); } else { - return await createRegistrationTokenConfig(githubRunnerConfig, runnerIds, ghClient, runnerConfigStore, options); + return await createRegistrationTokenConfig( + githubRunnerConfig, + runnerIds, + ghClient, + runnerConfigStore, + runnerStateStore, + options, + ); } } @@ -234,13 +251,15 @@ function addDelay(runnerIds: string[], runnerConfigStore: RunnerConfigStore) { /** * Creates registration token configuration for non-ephemeral runners. * - * @returns Empty array (this configuration method does not have failure cases) + * @returns Runner IDs whose durable configuration failed. The legacy SSM path + * still throws on the first failure so existing all-or-nothing cleanup is preserved. */ async function createRegistrationTokenConfig( githubRunnerConfig: CreateGitHubRunnerConfig, runnerIds: string[], ghClient: Octokit, runnerConfigStore: RunnerConfigStore, + runnerStateStore: RunnerStateStore | undefined, options: StartRunnerConfigOptions, ): Promise { const { isDelay, delay, delayMilliseconds } = addDelay(runnerIds, runnerConfigStore); @@ -251,18 +270,53 @@ async function createRegistrationTokenConfig( runner_service_config: removeTokenFromLogging(runnerServiceConfig), }); + if (!runnerStateStore) { + for (const runnerId of runnerIds) { + const metadata = options.getRunnerConfigMetadata?.(runnerId); + await runnerConfigStore.create(createRunnerConfigRecord(runnerId, runnerServiceConfig.join(' '), options), { + metadata, + }); + if (isDelay) { + // Delay to stay within the selected store's maximum write throughput. + await delay(delayMilliseconds); + } + } + return []; + } + + const failedRunnerIds: string[] = []; for (const runnerId of runnerIds) { - await runnerConfigStore.create( - { runnerId, value: runnerServiceConfig.join(' ') }, - { metadata: options.getRunnerConfigMetadata?.(runnerId) }, - ); - if (isDelay) { - // Delay to stay within the selected store's maximum write throughput. - await delay(delayMilliseconds); + try { + const metadata = options.getRunnerConfigMetadata?.(runnerId); + await createProvisioningRunnerState(runnerStateStore, githubRunnerConfig, runnerId, options, metadata); + await runnerConfigStore.create(createRunnerConfigRecord(runnerId, runnerServiceConfig.join(' '), options), { + metadata, + }); + await runnerStateStore.activate(runnerId, { metadata }); + if (isDelay) { + // Delay to stay within the selected store's maximum write throughput. + await delay(delayMilliseconds); + } + } catch (error) { + failedRunnerIds.push(runnerId); + logger.warn('Failed to configure runner, continuing with remaining instances', { + instance: runnerId, + error: error instanceof Error ? error.message : String(error), + retryable: true, + }); } } - return []; + if (failedRunnerIds.length > 0) { + logger.error('Failed to configure some runner instances', { + failedInstances: failedRunnerIds, + totalInstances: runnerIds.length, + successfulInstances: runnerIds.length - failedRunnerIds.length, + retryable: true, + }); + } + + return failedRunnerIds; } /** @@ -276,6 +330,7 @@ async function createJitConfig( runnerIds: string[], ghClient: Octokit, runnerConfigStore: RunnerConfigStore, + runnerStateStore: RunnerStateStore | undefined, options: StartRunnerConfigOptions, ): Promise { const runnerGroupId = await getRunnerGroupId(githubRunnerConfig, ghClient); @@ -293,6 +348,17 @@ async function createJitConfig( runnerGroupId: runnerGroupId, runnerLabels: runnerLabels, }; + // Durable inventory needs metadata before JIT creation so failures leave a + // complete provisioning record. The legacy path retains its original timing. + let metadata = runnerStateStore ? options.getRunnerConfigMetadata?.(runnerId) : undefined; + await createProvisioningRunnerState( + runnerStateStore, + githubRunnerConfig, + runnerId, + options, + metadata, + ephemeralRunnerConfig, + ); logger.debug(`Runner name: ${ephemeralRunnerConfig.runnerName}`); const runnerConfig = githubRunnerConfig.runnerType === 'Org' @@ -312,18 +378,47 @@ async function createJitConfig( metricGitHubAppRateLimit(runnerConfig.headers, githubRunnerConfig.appIndex); - await options.onJitConfigCreated?.(runnerId, { + const githubRunnerMetadata = { githubRunnerId: runnerConfig.data.runner.id.toString(), runnerLabels, - }); + }; + if (runnerStateStore) { + try { + await runnerStateStore.recordGitHubIdentity(runnerId, { + ...githubRunnerMetadata, + runnerName: ephemeralRunnerConfig.runnerName, + metadata, + }); + } catch (error) { + await deregisterJitRunnerAfterIdentityWriteFailure( + githubRunnerConfig, + ghClient, + runnerConfig.data.runner.id, + runnerId, + ); + throw error; + } + } + await options.onJitConfigCreated?.(runnerId, githubRunnerMetadata); logger.debug('Runner JIT config for ephemeral runner generated.', { instance: runnerId, }); + if (!runnerStateStore) { + metadata = options.getRunnerConfigMetadata?.(runnerId); + } await runnerConfigStore.create( - { runnerId, value: runnerConfig.data.encoded_jit_config }, - { metadata: options.getRunnerConfigMetadata?.(runnerId) }, + createRunnerConfigRecord(runnerId, runnerConfig.data.encoded_jit_config, options), + { + metadata, + }, ); + await runnerStateStore?.activate(runnerId, { + githubRunnerId: githubRunnerMetadata.githubRunnerId, + runnerLabels, + runnerName: ephemeralRunnerConfig.runnerName, + metadata, + }); if (isDelay) { // Delay to stay within the selected store's maximum write throughput. await delay(delayMilliseconds); @@ -350,6 +445,73 @@ async function createJitConfig( return failedRunnerIds; } +async function deregisterJitRunnerAfterIdentityWriteFailure( + githubRunnerConfig: CreateGitHubRunnerConfig, + ghClient: Octokit, + githubRunnerId: number, + runnerId: string, +): Promise { + try { + if (githubRunnerConfig.runnerType === 'Org') { + await ghClient.actions.deleteSelfHostedRunnerFromOrg({ + org: githubRunnerConfig.runnerOwner, + runner_id: githubRunnerId, + }); + } else { + const [owner, repo] = githubRunnerConfig.runnerOwner.split('/'); + await ghClient.actions.deleteSelfHostedRunnerFromRepo({ owner, repo, runner_id: githubRunnerId }); + } + logger.info('De-registered JIT GitHub runner after its inventory identity could not be stored', { + instance: runnerId, + githubRunnerId, + }); + } catch (cleanupError) { + logger.error('Failed to de-register JIT GitHub runner after its inventory identity could not be stored', { + instance: runnerId, + githubRunnerId, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + retryable: true, + }); + } +} + +function createRunnerConfigRecord( + runnerId: string, + value: string, + options: StartRunnerConfigOptions, +): RunnerConfigRecord { + const accessScope = options.getRunnerConfigAccessScope?.(runnerId); + return { + runnerId, + value, + ...(accessScope === undefined ? {} : { accessScope }), + }; +} + +async function createProvisioningRunnerState( + runnerStateStore: RunnerStateStore | undefined, + githubRunnerConfig: CreateGitHubRunnerConfig, + runnerId: string, + options: StartRunnerConfigOptions, + metadata: RunnerConfigMetadata[] | undefined, + jitConfig?: EphemeralRunnerConfig, +): Promise { + if (!runnerStateStore) { + return; + } + + await runnerStateStore.create({ + runnerId, + computeProvider: options.computeProvider!, + computeResourceId: runnerId, + runnerOwner: githubRunnerConfig.runnerOwner, + runnerType: githubRunnerConfig.runnerType, + runnerName: jitConfig?.runnerName, + runnerLabels: jitConfig?.runnerLabels, + metadata, + }); +} + export function getGitHubEnterpriseApiUrl() { const ghesBaseUrl = process.env.GHES_URL; let ghesApiUrl = ''; diff --git a/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts b/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts index c4ff1e5d76..dd1cd503ea 100644 --- a/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts @@ -12,6 +12,13 @@ vi.mock('../aws/sqs', async () => ({ publishMessage: vi.fn(), })); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getGitHubAppCredentialsStore: vi.fn(), + getRunnerConfigStore: vi.fn().mockReturnValue({ houseKeeper: vi.fn() }), + getRunnerGroupCacheStore: vi.fn(), + getRunnerStateStore: vi.fn().mockReturnValue(undefined), +})); + vi.mock('@aws-github-runner/aws-powertools-util', async () => { // This is a workaround for TypeScript's type checking // Use vi.importActual with a type assertion to avoid spread operator type error 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 afd25211da..fe51dc3a9c 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 @@ -7,6 +7,10 @@ import { controlPlaneProviderRegistry } from '../control-plane-providers'; import { scaleDown } from './scale-down'; import type { ScaleDownComputeProvider } from './types'; +vi.mock('@aws-github-runner/storage-providers', () => ({ + getRunnerStateStore: vi.fn().mockReturnValue(undefined), +})); + const mockedResolveCapability = vi.spyOn(controlPlaneProviderRegistry, 'capability'); const cleanEnv = process.env; diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts index 90320be856..0bcbddb7c6 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts @@ -1,5 +1,10 @@ import type { Octokit } from '@octokit/rest'; import { RequestError } from '@octokit/request-error'; +import { + getRunnerStateStore, + type RunnerStateRecord, + type RunnerStateStore, +} from '@aws-github-runner/storage-providers'; import moment from 'moment'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -16,6 +21,10 @@ vi.mock('../github/auth', () => ({ getStoredInstallationId: vi.fn().mockResolvedValue(undefined), })); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getRunnerStateStore: vi.fn(), +})); + const mockOctokit = { apps: { getOrgInstallation: vi.fn(), @@ -50,6 +59,18 @@ const mockBootTimeExceeded = vi.mocked(mockComputeProvider.bootTimeExceeded); const mockMarkOrphan = vi.mocked(mockComputeProvider.markOrphan); const mockUnmarkOrphan = vi.mocked(mockComputeProvider.unmarkOrphan); const mockTerminateRunners = vi.mocked(mockComputeProvider.terminate); +const mockGetRunnerStateStore = vi.mocked(getRunnerStateStore); +const mockRunnerStateStore: RunnerStateStore = { + create: vi.fn(), + recordGitHubIdentity: vi.fn(), + activate: vi.fn(), + list: vi.fn(), + markOrphan: vi.fn(), + unmarkOrphan: vi.fn(), + beginTermination: vi.fn(), + cancelTermination: vi.fn(), + delete: vi.fn(), +}; const cleanEnv = process.env; @@ -191,6 +212,14 @@ describe('Scale down runners', () => { mockMarkOrphan.mockResolvedValue(); mockUnmarkOrphan.mockResolvedValue(); mockTerminateRunners.mockResolvedValue(); + mockGetRunnerStateStore.mockReturnValue(undefined); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([]); + vi.mocked(mockRunnerStateStore.markOrphan).mockResolvedValue(); + vi.mocked(mockRunnerStateStore.unmarkOrphan).mockResolvedValue(); + vi.mocked(mockRunnerStateStore.activate).mockResolvedValue(); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValue('active'); + vi.mocked(mockRunnerStateStore.cancelTermination).mockResolvedValue(); + vi.mocked(mockRunnerStateStore.delete).mockResolvedValue(); mockOctokit.apps.getOrgInstallation.mockImplementation(() => ({ data: { @@ -691,6 +720,309 @@ describe('Scale down runners', () => { }); }); }); + + describe('with durable runner inventory', () => { + beforeEach(() => { + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + mockOctokit.actions.getSelfHostedRunnerForOrg.mockResolvedValue({ + data: { id: 101, name: 'runner', busy: false, status: 'online' }, + }); + mockOctokit.actions.deleteSelfHostedRunnerFromOrg.mockResolvedValue({ status: 204 }); + }); + + it('reconciles stored-only runners without terminating compute and preserves provider-only bypass removal', async () => { + const providerRunner = createRunnerTestData( + 'provider-only', + 'Org', + MINIMUM_TIME_RUNNING_IN_MINUTES + 1, + true, + false, + false, + ); + providerRunner.bypassRemoval = true; + const storedRecord = createRunnerStateRecord('i-stored-only-org', 'active', { + githubRunnerId: '101', + }); + + mockProviderRunners([providerRunner]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValueOnce([storedRecord]).mockResolvedValueOnce([]); + await scaleDown(); + + expect(mockTerminateRunners).not.toHaveBeenCalledWith(storedRecord.computeResourceId); + expect(mockTerminateRunners).not.toHaveBeenCalledWith(providerRunner.id); + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg).toHaveBeenCalledWith({ + org: storedRecord.runnerOwner, + runner_id: 101, + }); + expect(mockRunnerStateStore.delete).toHaveBeenCalledWith(storedRecord.runnerId); + }); + + it('keeps inventory lifecycle canonical when a tracked provider resource has a stale orphan tag', async () => { + const providerRunner = createRunnerTestData('tracked-active', 'Org', MINIMUM_BOOT_TIME - 1, false, true, false); + const storedRecord = createRunnerStateRecord(providerRunner.id, 'active'); + + mockProviderRunners([providerRunner]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([storedRecord]); + mockOctokit.paginate.mockResolvedValue([]); + + await scaleDown(); + + expect(mockTerminateRunners).not.toHaveBeenCalled(); + expect(mockUnmarkOrphan).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.beginTermination).not.toHaveBeenCalled(); + }); + + it('claims provider-absent active state before GitHub cleanup and deletes it without compute calls', async () => { + const storedRecord = createRunnerStateRecord('i-active-org', 'active', { githubRunnerId: '101' }); + mockProviderRunners([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValueOnce([storedRecord]).mockResolvedValueOnce([]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('active'); + await scaleDown(); + + expect(mockRunnerStateStore.beginTermination).toHaveBeenCalledWith(storedRecord.runnerId); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.delete).toHaveBeenCalledWith(storedRecord.runnerId); + expect(vi.mocked(mockRunnerStateStore.beginTermination).mock.invocationCallOrder[0]).toBeLessThan( + mockOctokit.actions.deleteSelfHostedRunnerFromOrg.mock.invocationCallOrder[0], + ); + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockRunnerStateStore.delete).mock.invocationCallOrder[0], + ); + }); + + it('treats a GitHub 404 as successful cleanup for provider-absent inventory', async () => { + const storedRecord = createRunnerStateRecord('i-missing-github-runner-org', 'provisioning', { + githubRunnerId: '101', + }); + const error404 = new RequestError('Runner not found', 404, { + request: { + method: 'DELETE', + url: 'https://api.github.com/test', + headers: {}, + }, + }); + mockProviderRunners([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValueOnce([storedRecord]).mockResolvedValueOnce([]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('provisioning'); + mockOctokit.actions.deleteSelfHostedRunnerFromOrg.mockRejectedValueOnce(error404); + + await scaleDown(); + + expect(mockRunnerStateStore.delete).toHaveBeenCalledWith(storedRecord.runnerId); + expect(mockRunnerStateStore.cancelTermination).not.toHaveBeenCalled(); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + expect(mockUnmarkOrphan).not.toHaveBeenCalled(); + expect(mockOctokit.actions.getSelfHostedRunnerForOrg).not.toHaveBeenCalled(); + }); + + it('skips cleanup when another invocation owns the termination claim', async () => { + const storedRecord = createRunnerStateRecord('i-contended-org', 'active', { githubRunnerId: '101' }); + mockProviderRunners([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([storedRecord]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce(undefined); + mockOctokit.paginate.mockResolvedValue([{ id: 101, name: storedRecord.computeResourceId }]); + + await scaleDown(); + + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg).not.toHaveBeenCalled(); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.delete).not.toHaveBeenCalled(); + }); + + it('reclaims a stale terminating provider-absent record without calling compute', async () => { + const terminatingRecord = createRunnerStateRecord('i-stale-terminating-org', 'terminating'); + mockProviderRunners([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValueOnce([terminatingRecord]).mockResolvedValueOnce([]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('terminating'); + + await scaleDown(); + + expect(mockRunnerStateStore.beginTermination).toHaveBeenCalledWith(terminatingRecord.runnerId); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.delete).toHaveBeenCalledWith(terminatingRecord.runnerId); + expect(mockRunnerStateStore.cancelTermination).not.toHaveBeenCalled(); + }); + + it('leaves a reclaimed provider-absent record terminating when state deletion fails', async () => { + const terminatingRecord = createRunnerStateRecord('i-terminating-retry-failure-org', 'terminating'); + mockProviderRunners([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([terminatingRecord]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('terminating'); + vi.mocked(mockRunnerStateStore.delete).mockRejectedValueOnce(new Error('state deletion failed')); + + await scaleDown(); + + expect(mockRunnerStateStore.cancelTermination).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.delete).toHaveBeenCalledWith(terminatingRecord.runnerId); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + }); + + it('restores the exact prior lifecycle state when compute termination fails', async () => { + const providerRunner = createRunnerTestData( + 'stale-provisioning', + 'Org', + MINIMUM_BOOT_TIME + 1, + false, + false, + false, + ); + const staleProvisioning = createRunnerStateRecord(providerRunner.id, 'provisioning'); + mockProviderRunners([providerRunner]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([staleProvisioning]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('provisioning'); + mockTerminateRunners.mockRejectedValueOnce(new Error('termination failed')); + + await scaleDown(); + + expect(mockRunnerStateStore.beginTermination).toHaveBeenCalledWith(staleProvisioning.runnerId); + expect(mockRunnerStateStore.cancelTermination).toHaveBeenCalledWith(staleProvisioning.runnerId, 'provisioning'); + expect(mockRunnerStateStore.delete).not.toHaveBeenCalled(); + }); + + it('de-registers a stored orphan before terminating compute and deleting state', async () => { + const providerRunner = createRunnerTestData( + 'orphan-with-github-runner', + 'Org', + MINIMUM_BOOT_TIME + 1, + false, + true, + true, + ); + const storedOrphan = createRunnerStateRecord(providerRunner.id, 'orphan', { + githubRunnerId: '101', + }); + mockProviderRunners([providerRunner]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValueOnce([storedOrphan]).mockResolvedValueOnce([]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('orphan'); + mockOctokit.actions.getSelfHostedRunnerForOrg.mockResolvedValueOnce({ + data: { id: 101, name: storedOrphan.computeResourceId, busy: true, status: 'offline' }, + }); + + await scaleDown(); + + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg).toHaveBeenCalledWith({ + org: storedOrphan.runnerOwner, + runner_id: 101, + }); + expect(mockTerminateRunners).toHaveBeenCalledWith(storedOrphan.computeResourceId); + expect(mockRunnerStateStore.delete).toHaveBeenCalledWith(storedOrphan.runnerId); + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg.mock.invocationCallOrder[0]).toBeLessThan( + mockTerminateRunners.mock.invocationCallOrder[0], + ); + expect(mockTerminateRunners.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockRunnerStateStore.delete).mock.invocationCallOrder[0], + ); + }); + + it('restores an orphan claim when GitHub cleanup fails', async () => { + const storedOrphan = createRunnerStateRecord('i-orphan-cleanup-failure-org', 'orphan', { + githubRunnerId: '101', + }); + mockProviderRunners([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([storedOrphan]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('orphan'); + mockOctokit.actions.deleteSelfHostedRunnerFromOrg.mockResolvedValueOnce({ status: 500 }); + + await scaleDown(); + + expect(mockRunnerStateStore.cancelTermination).toHaveBeenCalledWith(storedOrphan.runnerId, 'orphan'); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.delete).not.toHaveBeenCalled(); + }); + + it('does not restore state when deletion fails after compute termination succeeds', async () => { + const providerRunner = createRunnerTestData( + 'delete-failure', + 'Org', + MINIMUM_TIME_RUNNING_IN_MINUTES + 1, + true, + false, + true, + ); + const storedRecord = createRunnerStateRecord(providerRunner.id, 'active', { githubRunnerId: '101' }); + mockProviderRunners([providerRunner]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([storedRecord]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('active'); + vi.mocked(mockRunnerStateStore.delete).mockRejectedValueOnce(new Error('delete failed')); + mockOctokit.paginate.mockResolvedValue([{ id: 101, name: storedRecord.computeResourceId }]); + + await expect(scaleDown()).resolves.not.toThrow(); + + expect(mockTerminateRunners).toHaveBeenCalledWith(providerRunner.id); + expect(mockRunnerStateStore.cancelTermination).not.toHaveBeenCalled(); + }); + + it('updates durable lifecycle only after provider orphan removal succeeds', async () => { + const providerRunner = createRunnerTestData( + 'tracked-orphan', + 'Org', + MINIMUM_BOOT_TIME + 1, + false, + true, + false, + undefined, + 101, + ); + const storedOrphan = createRunnerStateRecord(providerRunner.id, 'orphan', { githubRunnerId: '101' }); + mockProviderRunners([providerRunner]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([storedOrphan]); + mockOctokit.actions.getSelfHostedRunnerForOrg.mockResolvedValueOnce({ + data: { id: 101, name: providerRunner.id, busy: false, status: 'online' }, + }); + + await scaleDown(); + + expect(mockUnmarkOrphan).toHaveBeenCalledWith(providerRunner.id); + expect(mockRunnerStateStore.unmarkOrphan).toHaveBeenCalledWith(storedOrphan.runnerId); + expect(mockUnmarkOrphan.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockRunnerStateStore.unmarkOrphan).mock.invocationCallOrder[0], + ); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + }); + + it('retains durable orphan state when provider orphan removal fails', async () => { + const providerRunner = createRunnerTestData( + 'tracked-orphan-failure', + 'Org', + MINIMUM_BOOT_TIME + 1, + false, + true, + false, + undefined, + 101, + ); + const storedOrphan = createRunnerStateRecord(providerRunner.id, 'orphan', { githubRunnerId: '101' }); + mockProviderRunners([providerRunner]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([storedOrphan]); + mockOctokit.actions.getSelfHostedRunnerForOrg.mockResolvedValueOnce({ + data: { id: 101, name: providerRunner.id, busy: false, status: 'online' }, + }); + mockUnmarkOrphan.mockRejectedValueOnce(new Error('provider unmark failed')); + + await scaleDown(); + + expect(mockRunnerStateStore.unmarkOrphan).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.activate).not.toHaveBeenCalled(); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + }); + + it('reconciles expired provisioning records but leaves fresh provisioning records alone', async () => { + const staleProvisioning = createRunnerStateRecord('i-stale-provisioning-org', 'provisioning'); + const freshProvisioning = createRunnerStateRecord('i-fresh-provisioning-org', 'provisioning', { + createdAt: new Date().toISOString(), + }); + mockProviderRunners([]); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([staleProvisioning, freshProvisioning]); + vi.mocked(mockRunnerStateStore.beginTermination).mockResolvedValueOnce('provisioning'); + + await scaleDown(); + + expect(mockRunnerStateStore.beginTermination).toHaveBeenCalledTimes(1); + expect(mockRunnerStateStore.beginTermination).toHaveBeenCalledWith(staleProvisioning.runnerId); + expect(mockTerminateRunners).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.delete).toHaveBeenCalledWith(staleProvisioning.runnerId); + }); + }); }); function mockProviderRunners(runners: RunnerTestItem[]) { @@ -751,3 +1083,24 @@ function createRunnerTestData( bypassRemoval: false, }; } + +function createRunnerStateRecord( + runnerId: string, + state: RunnerStateRecord['state'], + overrides: Partial = {}, +): RunnerStateRecord { + const timestamp = moment(new Date()) + .subtract(MINIMUM_TIME_RUNNING_IN_MINUTES + 1, 'minutes') + .toISOString(); + return { + runnerId, + computeProvider: 'ec2', + computeResourceId: runnerId, + runnerOwner: TEST_DATA.repositoryOwner, + runnerType: 'Org', + state, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides, + }; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts index 3e387bce06..5e8aaa9f50 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts @@ -3,6 +3,11 @@ import { Endpoints } from '@octokit/types'; import { RequestError } from '@octokit/request-error'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { + getRunnerStateStore, + type RunnerStateRecord, + type RunnerStateStore, +} from '@aws-github-runner/storage-providers'; import moment from 'moment'; import { @@ -22,7 +27,19 @@ const logger = createChildLogger('scale-down'); type OrgRunnerList = Endpoints['GET /orgs/{org}/actions/runners']['response']['data']['runners']; type RepoRunnerList = Endpoints['GET /repos/{owner}/{repo}/actions/runners']['response']['data']['runners']; -type RunnerState = OrgRunnerList[number] | RepoRunnerList[number]; +type GitHubRunnerState = OrgRunnerList[number] | RepoRunnerList[number]; + +interface InventoryRunnerInfo extends RunnerInfo { + inventory?: RunnerStateRecord; + providerPresent?: boolean; +} + +type RestorableRunnerState = Parameters[1]; + +interface TerminationClaim { + runnerStateId?: string; + restoreState?: RestorableRunnerState; +} async function getOrCreateOctokit(runner: RunnerInfo): Promise { const key = runner.owner; @@ -67,7 +84,7 @@ async function getGitHubSelfHostedRunnerState( client: Octokit, runner: RunnerInfo, runnerId: number, -): Promise { +): Promise { try { const state = runner.type === 'Org' @@ -161,6 +178,12 @@ async function deleteGitHubRunner( } return { ghRunnerId, status: response.status, success: response.status === 204 }; } catch (error) { + if (error instanceof RequestError && error.status === 404) { + logger.info( + `GitHub runner ${ghRunnerId} for runner '${runner.id}' is already de-registered; treating cleanup as complete.`, + ); + return { ghRunnerId, status: error.status, success: true }; + } logger.error( `Failed to de-register GitHub runner ${ghRunnerId} for runner '${runner.id}'. ` + `Error: ${error instanceof Error ? error.message : String(error)}`, @@ -171,11 +194,12 @@ async function deleteGitHubRunner( } async function removeRunner( - runner: RunnerInfo, + runner: InventoryRunnerInfo, ghRunnerIds: number[], computeProvider: ScaleDownComputeProvider, ): Promise { - const githubInstallationClient = await getOrCreateOctokit(runner); + let terminationClaim: TerminationClaim | undefined; + let computeTerminated = false; try { if (runner.bypassRemoval) { logger.info( @@ -184,6 +208,7 @@ async function removeRunner( return; } + const githubInstallationClient = await getOrCreateOctokit(runner); const states = await Promise.all( ghRunnerIds.map(async (ghRunnerId) => { // Get busy state instead of using the output of listGitHubRunners(...) to minimize to race condition. @@ -192,6 +217,13 @@ async function removeRunner( ); if (states.every((busy) => busy === false)) { + const claim = await beginRunnerTermination(runner); + if (claim === undefined) { + logger.info(`Runner '${runner.id}' is already being reconciled; skipping this scale-down cycle.`); + return; + } + terminationClaim = claim; + const results = await Promise.all( ghRunnerIds.map((ghRunnerId) => deleteGitHubRunner(githubInstallationClient, runner, ghRunnerId)), ); @@ -201,10 +233,15 @@ async function removeRunner( if (allSucceeded) { await computeProvider.terminate(runner.id); + computeTerminated = true; + await completeRunnerTermination(terminationClaim); + terminationClaim = undefined; logger.info( `${computeProvider.type.toUpperCase()} runner '${runner.id}' is terminated and GitHub runner is de-registered.`, ); } else { + await restoreRunnerTermination(terminationClaim); + terminationClaim = undefined; // Only terminate the provider runner if it was successfully de-registered from GitHub. logger.error( `Failed to de-register ${failedRunners.length} GitHub runner(s) for runner '${runner.id}'. ` + @@ -216,6 +253,9 @@ async function removeRunner( logger.info(`Runner '${runner.id}' cannot be de-registered, because it is still busy.`); } } catch (e) { + if (terminationClaim && !computeTerminated) { + await restoreRunnerTermination(terminationClaim); + } logger.error( `Runner '${runner.id}' cannot be de-registered. Error: ${e instanceof Error ? e.message : String(e)}`, { error: e }, @@ -224,7 +264,7 @@ async function removeRunner( } async function evaluateAndRemoveRunners( - runners: RunnerInfo[], + runners: InventoryRunnerInfo[], scaleDownConfigs: ScalingDownConfigList, computeProvider: ScaleDownComputeProvider, ): Promise { @@ -237,14 +277,27 @@ async function evaluateAndRemoveRunners( .filter((runner) => runner.owner === ownerTag) .sort(evictionStrategy === 'oldest_first' ? oldestFirstStrategy : newestFirstStrategy); logger.debug(`Found: '${ownerRunners.length}' active GitHub runners with owner tag: '${ownerTag}'`); - logger.debug(`Active GitHub runners with owner tag: '${ownerTag}': ${JSON.stringify(ownerRunners)}`); + if (ownerRunners.some((runner) => runner.inventory)) { + logger.debug(`Active GitHub runner inventory with owner tag: '${ownerTag}'`, { + runners: ownerRunners.map((runner) => ({ + computeResourceId: runner.id, + lifecycleState: runner.inventory?.state, + })), + }); + } else { + logger.debug(`Active GitHub runners with owner tag: '${ownerTag}': ${JSON.stringify(ownerRunners)}`); + } for (const runner of ownerRunners) { if (runner.bypassRemoval) { logger.debug(`Runner '${runner.id}' has bypass-removal tag set, skipping evaluation.`); continue; } const ghRunners = await listGitHubRunners(runner); - const ghRunnersFiltered = ghRunners.filter((ghRunner: { name: string }) => ghRunner.name.endsWith(runner.id)); + const ghRunnersFiltered = ghRunners.filter((ghRunner: { id: number; name: string }) => + runner.inventory?.githubRunnerId + ? ghRunner.id.toString() === runner.inventory.githubRunnerId + : ghRunner.name.endsWith(runner.id), + ); logger.debug(`Found: '${ghRunnersFiltered.length}' GitHub runners for runner: '${runner.id}'`); logger.debug(`GitHub runners for runner: '${runner.id}': ${JSON.stringify(ghRunnersFiltered)}`); if (ghRunnersFiltered.length) { @@ -262,7 +315,7 @@ async function evaluateAndRemoveRunners( } } } else if (computeProvider.bootTimeExceeded(runner)) { - await markOrphan(runner.id, computeProvider); + await markOrphan(runner, computeProvider); } else { logger.debug(`Runner ${runner.id} has not yet booted.`); } @@ -270,25 +323,46 @@ async function evaluateAndRemoveRunners( } } -async function markOrphan(id: string, computeProvider: ScaleDownComputeProvider): Promise { +async function markOrphan(runner: InventoryRunnerInfo, computeProvider: ScaleDownComputeProvider): Promise { try { - await computeProvider.markOrphan(id); - logger.info(`Runner '${id}' tagged as orphan.`); + if (runner.inventory) { + await getRunnerStateStore()?.markOrphan(runner.inventory.runnerId); + } + await computeProvider.markOrphan(runner.id); + logger.info(`Runner '${runner.id}' tagged as orphan.`); } catch (e) { - logger.error(`Failed to tag runner '${id}' as orphan.`, { error: e }); + logger.error(`Failed to tag runner '${runner.id}' as orphan.`, { error: e }); } } -async function unMarkOrphan(id: string, computeProvider: ScaleDownComputeProvider): Promise { +async function unMarkOrphan(runner: InventoryRunnerInfo, computeProvider: ScaleDownComputeProvider): Promise { try { - await computeProvider.unmarkOrphan(id); - logger.info(`Runner '${id}' untagged as orphan.`); + // Keep the durable lifecycle unchanged if the provider mutation fails. In + // particular, activating a provisioning record removes its safety TTL. + await computeProvider.unmarkOrphan(runner.id); + const runnerStateStore = getRunnerStateStore(); + if (runnerStateStore && runner.inventory?.state === 'orphan') { + await runnerStateStore.unmarkOrphan(runner.inventory.runnerId); + } else if (runnerStateStore && runner.inventory?.state === 'provisioning') { + await runnerStateStore.activate(runner.inventory.runnerId, { + githubRunnerId: runner.githubRunnerId, + runnerLabels: runner.inventory.runnerLabels, + runnerName: runner.inventory.runnerName, + metadata: runner.inventory.metadata, + }); + } + logger.info(`Runner '${runner.id}' untagged as orphan.`); } catch (e) { - logger.error(`Failed to un-tag runner '${id}' as orphan.`, { error: e }); + logger.error(`Failed to un-tag runner '${runner.id}' as orphan.`, { error: e }); } } -async function lastChanceCheckOrphanRunner(runner: RunnerInfo): Promise { +interface OrphanEvaluation { + isOrphan: boolean; + githubRunnerExists: boolean; +} + +async function lastChanceCheckOrphanRunner(runner: InventoryRunnerInfo): Promise { const client = await getOrCreateOctokit(runner); const runnerId = parseInt(runner.githubRunnerId || '0'); const state = await getGitHubSelfHostedRunnerState(client, runner, runnerId); @@ -305,30 +379,52 @@ async function lastChanceCheckOrphanRunner(runner: RunnerInfo): Promise } } logger.info(`Runner '${runner.id}' is judged to ${isOrphan ? 'be' : 'not be'} orphaned.`); - return isOrphan; + return { isOrphan, githubRunnerExists: state !== null }; } -async function terminateOrphan(environment: string, computeProvider: ScaleDownComputeProvider): Promise { +async function terminateOrphan( + environment: string, + computeProvider: ScaleDownComputeProvider, + runners?: InventoryRunnerInfo[], +): Promise { try { - const orphanRunners = await computeProvider.list(environment, true); + const orphanRunners: InventoryRunnerInfo[] = runners ?? (await computeProvider.list(environment, true)); for (const runner of orphanRunners) { if (runner.bypassRemoval) { logger.info(`Orphan runner '${runner.id}' has bypass-removal tag set, skipping termination.`); continue; } + if (runner.inventory?.state === 'provisioning' && !computeProvider.bootTimeExceeded(runner)) { + logger.debug(`Runner '${runner.id}' is still provisioning; skipping reconciliation until boot time expires.`); + continue; + } if (runner.githubRunnerId) { - const isOrphan = await lastChanceCheckOrphanRunner(runner); - if (isOrphan) { - await computeProvider.terminate(runner.id); + const orphanEvaluation = await lastChanceCheckOrphanRunner(runner); + if (orphanEvaluation.isOrphan) { + if (runner.inventory) { + await terminateClaimedRunner( + runner, + computeProvider, + orphanEvaluation.githubRunnerExists ? parseInt(runner.githubRunnerId) : undefined, + ); + } else { + // Preserve the provider-only recovery behavior used by the legacy path. + await computeProvider.terminate(runner.id); + } } else { - await unMarkOrphan(runner.id, computeProvider); + await unMarkOrphan(runner, computeProvider); } } else { logger.info(`Terminating orphan runner '${runner.id}'`); - await computeProvider.terminate(runner.id).catch((e) => { - logger.error(`Failed to terminate orphan runner '${runner.id}'`, { error: e }); - }); + if (runner.inventory) { + await terminateClaimedRunner(runner, computeProvider); + } else { + // Preserve the provider-only recovery behavior used by the legacy path. + await computeProvider.terminate(runner.id).catch((error) => { + logger.error(`Failed to terminate orphan runner '${runner.id}'`, { error }); + }); + } } } } catch (e) { @@ -336,6 +432,136 @@ async function terminateOrphan(environment: string, computeProvider: ScaleDownCo } } +async function terminateClaimedRunner( + runner: InventoryRunnerInfo, + computeProvider: ScaleDownComputeProvider, + githubRunnerId?: number, +): Promise { + const claim = await beginRunnerTermination(runner); + if (claim === undefined) { + logger.info(`Runner '${runner.id}' is already being reconciled; skipping this scale-down cycle.`); + return; + } + + try { + if (githubRunnerId !== undefined) { + const githubInstallationClient = await getOrCreateOctokit(runner); + const result = await deleteGitHubRunner(githubInstallationClient, runner, githubRunnerId); + if (!result.success) { + await restoreRunnerTermination(claim); + logger.error( + `Failed to de-register GitHub runner '${githubRunnerId}' for orphan runner '${runner.id}'. ` + + `Runner will NOT be terminated to allow retry on next scale-down cycle.`, + ); + return; + } + } + await computeProvider.terminate(runner.id); + } catch (error) { + await restoreRunnerTermination(claim); + logger.error(`Failed to clean up orphan runner '${runner.id}'`, { error }); + return; + } + + try { + await completeRunnerTermination(claim); + } catch (error) { + logger.error(`Failed to delete runner state for terminated runner '${runner.id}'`, { error }); + } +} + +async function reconcileProviderAbsentRunner( + runner: InventoryRunnerInfo, + computeProvider: ScaleDownComputeProvider, +): Promise { + if (!runner.inventory || runner.providerPresent !== false) { + return; + } + + // Provider discovery can lag immediately after launch. Retain the inventory + // until the normal boot grace has elapsed before declaring compute absent. + if (!computeProvider.bootTimeExceeded(runner)) { + logger.debug(`Runner '${runner.id}' is absent from provider discovery but remains inside its boot grace.`); + return; + } + + const claim = await beginRunnerTermination(runner); + if (claim === undefined) { + logger.info(`Runner '${runner.id}' is already being reconciled; skipping this scale-down cycle.`); + return; + } + + try { + if (runner.githubRunnerId) { + const githubInstallationClient = await getOrCreateOctokit(runner); + const result = await deleteGitHubRunner(githubInstallationClient, runner, parseInt(runner.githubRunnerId, 10)); + if (!result.success) { + await restoreRunnerTermination(claim); + logger.error( + `Failed to de-register GitHub runner '${runner.githubRunnerId}' for provider-absent runner ` + + `'${runner.id}'. Runner state will be retained for retry.`, + ); + return; + } + } + } catch (error) { + await restoreRunnerTermination(claim); + logger.error(`Failed to reconcile GitHub state for provider-absent runner '${runner.id}'`, { error }); + return; + } + + // Provider discovery already established that the compute resource is gone; + // avoid tag/terminate calls that would turn idempotent cleanup into a retry loop. + try { + await completeRunnerTermination(claim); + logger.info(`Removed durable state for provider-absent runner '${runner.id}'.`); + } catch (error) { + // GitHub cleanup is committed at this point. Leave the terminating claim in + // place so its lease/TTL makes state deletion retryable without resurrection. + logger.error(`Failed to delete runner state for provider-absent runner '${runner.id}'`, { error }); + } +} + +async function beginRunnerTermination(runner: InventoryRunnerInfo): Promise { + if (!runner.inventory) { + return {}; + } + + const runnerStateStore = getRunnerStateStore(); + if (!runnerStateStore) { + return {}; + } + + const previousState = await runnerStateStore.beginTermination(runner.inventory.runnerId); + if (previousState === undefined) { + return undefined; + } + return { + runnerStateId: runner.inventory.runnerId, + // A reclaimed terminating record has no known pre-claim lifecycle state. + // Leave it terminating on failure so the lease can make it retryable again. + ...(previousState === 'terminating' ? {} : { restoreState: previousState }), + }; +} + +async function restoreRunnerTermination(claim: TerminationClaim): Promise { + if (!claim.runnerStateId || !claim.restoreState) { + return; + } + + try { + await getRunnerStateStore()?.cancelTermination(claim.runnerStateId, claim.restoreState); + } catch (error) { + logger.error(`Failed to restore runner state for '${claim.runnerStateId}' after termination failure.`, { error }); + } +} + +async function completeRunnerTermination(claim: TerminationClaim): Promise { + if (claim.runnerStateId) { + await getRunnerStateStore()?.delete(claim.runnerStateId); + } +} + export function oldestFirstStrategy(a: RunnerInfo, b: RunnerInfo): number { if (a.launchTime === undefined) return 1; if (b.launchTime === undefined) return 1; @@ -352,12 +578,52 @@ async function listRunners(environment: string, computeProvider: ScaleDownComput return await computeProvider.list(environment); } -function filterRunners(runners: RunnerInfo[]): RunnerInfo[] { +function filterRunners(runners: InventoryRunnerInfo[]): InventoryRunnerInfo[] { // Managed runners are launched with owner and type tags together. Exclude incomplete records because both // values are required to select the GitHub owner and runner API used during scale-down. return runners.filter((runner) => runner.owner && runner.type && !runner.orphan); } +function mergeRunnerInventory(records: RunnerStateRecord[], providerRunners: RunnerInfo[]): InventoryRunnerInfo[] { + const runnersByComputeResource = new Map(); + for (const record of records) { + runnersByComputeResource.set(record.computeResourceId, { + id: record.computeResourceId, + launchTime: new Date(record.createdAt), + owner: record.runnerOwner, + type: record.runnerType, + orphan: record.state !== 'active', + githubRunnerId: record.githubRunnerId, + inventory: record, + providerPresent: false, + }); + } + + for (const providerRunner of providerRunners) { + const storedRunner = runnersByComputeResource.get(providerRunner.id); + if (!storedRunner) { + runnersByComputeResource.set(providerRunner.id, { ...providerRunner, providerPresent: true }); + continue; + } + + runnersByComputeResource.set(providerRunner.id, { + ...storedRunner, + ...providerRunner, + id: storedRunner.id, + owner: storedRunner.owner, + type: storedRunner.type, + // Lifecycle state is canonical for tracked resources. Provider tags are + // retained as recovery data only for resources missing from inventory. + orphan: storedRunner.orphan, + githubRunnerId: storedRunner.githubRunnerId ?? providerRunner.githubRunnerId, + inventory: storedRunner.inventory, + providerPresent: true, + }); + } + + return Array.from(runnersByComputeResource.values()); +} + export async function scaleDown(): Promise { githubCache.reset(); const environment = process.env.ENVIRONMENT; @@ -367,28 +633,70 @@ export async function scaleDown(): Promise { ...controlPlaneProviderRegistry.capability(computeProviderType, 'scaleDown')(), type: computeProviderType, }; + const runnerStateStore = getRunnerStateStore(); + let managedRunners: InventoryRunnerInfo[]; + let runnersToEvaluate: InventoryRunnerInfo[]; + + if (!runnerStateStore) { + // Preserve the legacy SSM-backed lifecycle. EC2 remains the source of truth + // when no durable runner inventory is configured. + await terminateOrphan(environment, computeProvider); + managedRunners = await listRunners(environment, computeProvider); + runnersToEvaluate = managedRunners; + } else { + const providerRunners = await listRunners(environment, computeProvider); + const inventoryRecords = await runnerStateStore.list({ computeProvider: computeProvider.type }); + managedRunners = mergeRunnerInventory(inventoryRecords, providerRunners); + + const providerAbsentRunners = managedRunners.filter( + (runner) => runner.inventory !== undefined && runner.providerPresent === false, + ); + for (const runner of providerAbsentRunners) { + await reconcileProviderAbsentRunner(runner, computeProvider); + } + runnersToEvaluate = managedRunners.filter( + (runner) => runner.inventory === undefined || runner.providerPresent !== false, + ); - // first runners marked to be orphan. - await terminateOrphan(environment, computeProvider); + // Reconcile stale provisioning and orphan records first. Provider tags remain + // a recovery signal for resources that predate the durable inventory. + await terminateOrphan( + environment, + computeProvider, + runnersToEvaluate.filter((runner) => runner.orphan), + ); + } - // next scale down idle runners with respect to config and mark potential orphans - const providerRunners = await listRunners(environment, computeProvider); - const activeProviderRunnersCount = providerRunners.length; + const runnerCountLabel = runnerStateStore ? 'managed' : 'active'; + const managedRunnerCount = managedRunners.length; logger.info( - `Found: '${activeProviderRunnersCount}' active ${computeProvider.type.toUpperCase()} runners before clean-up.`, + `Found: '${managedRunnerCount}' ${runnerCountLabel} ${computeProvider.type.toUpperCase()} runners before clean-up.`, ); - logger.debug(`Active ${computeProvider.type.toUpperCase()} runners: ${JSON.stringify(providerRunners)}`); + if (runnerStateStore) { + logger.debug(`Active ${computeProvider.type.toUpperCase()} runner inventory`, { + runners: managedRunners.map((runner) => ({ + computeResourceId: runner.id, + lifecycleState: runner.inventory?.state, + })), + }); + } else { + logger.debug(`Active ${computeProvider.type.toUpperCase()} runners: ${JSON.stringify(managedRunners)}`); + } - if (activeProviderRunnersCount === 0) { - logger.debug(`No active runners found for environment: '${environment}'`); + if (managedRunnerCount === 0) { + logger.debug(`No ${runnerCountLabel} runners found for environment: '${environment}'`); return; } - const runners = filterRunners(providerRunners); + const runners = filterRunners(runnersToEvaluate); await evaluateAndRemoveRunners(runners, scaleDownConfigs, computeProvider); - const activeProviderRunnersCountAfter = (await listRunners(environment, computeProvider)).length; + const providerRunnersAfter = await listRunners(environment, computeProvider); + const managedRunnerCountAfter = runnerStateStore + ? mergeRunnerInventory(await runnerStateStore.list({ computeProvider: computeProvider.type }), providerRunnersAfter) + .length + : providerRunnersAfter.length; logger.info( - `Found: '${activeProviderRunnersCountAfter}' active ${computeProvider.type.toUpperCase()} runners after clean-up.`, + `Found: '${managedRunnerCountAfter}' ${runnerCountLabel} ${computeProvider.type.toUpperCase()} runners after clean-up.`, ); } 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 3c1a0362bb..4307d602b8 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 @@ -15,6 +15,12 @@ vi.mock('../github/auth', () => ({ createOctokitClient: vi.fn(), })); +vi.mock('@aws-github-runner/storage-providers', () => ({ + getRunnerConfigStore: vi.fn(), + getRunnerGroupCacheStore: vi.fn(), + getRunnerStateStore: vi.fn().mockReturnValue(undefined), +})); + vi.mock('./github-runner', async (importOriginal) => ({ ...(await importOriginal()), getGitHubEnterpriseApiUrl: vi.fn(), diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 4ab47c0bba..c7a8d1ae6f 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -1,8 +1,11 @@ import { getRunnerConfigStore, getRunnerGroupCacheStore, + getRunnerStateStore, type RunnerConfigStore, type RunnerGroupCacheStore, + type RunnerStateRecord, + type RunnerStateStore, } from '@aws-github-runner/storage-providers'; import type { Octokit } from '@octokit/rest'; import nock from 'nock'; @@ -15,6 +18,7 @@ import { publishRetryMessage } from './job-retry'; import * as scaleUpModule from './scale-up'; import type { ActionRequestMessageSQS, + CreateGitHubRunnerConfig, CreateRunnerResult, CreateScaleUpRunnersInput, ScaleUpComputeProvider, @@ -29,6 +33,8 @@ const mockOctokit = { getJobForWorkflowRun: vi.fn(), generateRunnerJitconfigForOrg: vi.fn(), generateRunnerJitconfigForRepo: vi.fn(), + deleteSelfHostedRunnerFromOrg: vi.fn(), + deleteSelfHostedRunnerFromRepo: vi.fn(), }, apps: { getOrgInstallation: vi.fn(), @@ -50,11 +56,12 @@ interface TestRunnerLookupInput { } const createRunner = vi.fn<(input: TestRunnerCreationInput) => Promise>(); -const listRunners = vi.fn<(input: TestRunnerLookupInput) => Promise>(); +const listRunners = vi.fn<(input: TestRunnerLookupInput) => Promise<{ id: string }[]>>(); const mockCreateRunner = vi.mocked(createRunner); const mockListRunners = vi.mocked(listRunners); const mockGetRunnerConfigStore = vi.mocked(getRunnerConfigStore); const mockGetRunnerGroupCacheStore = vi.mocked(getRunnerGroupCacheStore); +const mockGetRunnerStateStore = vi.mocked(getRunnerStateStore); const mockRunnerConfigCreate = vi.fn(); const mockRunnerConfigHouseKeeper = vi.fn(); const mockRunnerGroupCacheGet = vi.fn(); @@ -68,6 +75,17 @@ const mockRunnerGroupCacheStore: RunnerGroupCacheStore = { get: mockRunnerGroupCacheGet, create: mockRunnerGroupCacheCreate, }; +const mockRunnerStateStore: RunnerStateStore = { + create: vi.fn(), + recordGitHubIdentity: vi.fn(), + activate: vi.fn(), + list: vi.fn(), + markOrphan: vi.fn(), + unmarkOrphan: vi.fn(), + beginTermination: vi.fn(), + cancelTermination: vi.fn(), + delete: vi.fn(), +}; const mockPublishRetryMessage = vi.mocked(publishRetryMessage); const testProviderState = { provider: 'test' }; const mockComputeProvider: ScaleUpComputeProvider = { @@ -102,6 +120,7 @@ vi.mock('../github/auth', async () => ({ vi.mock('@aws-github-runner/storage-providers', () => ({ getRunnerConfigStore: vi.fn(), getRunnerGroupCacheStore: vi.fn(), + getRunnerStateStore: vi.fn(), })); vi.mock('./job-retry', () => ({ @@ -153,6 +172,7 @@ function setDefaults() { process.env.GITHUB_APP_CLIENT_SECRET = 'TEST_CLIENT_SECRET'; process.env.RUNNERS_MAXIMUM_COUNT = '3'; process.env.ENVIRONMENT = EXPECTED_RUNNER_PARAMS.environment; + delete process.env.EC2_INSTANCE_ARN_PREFIX; } async function createTestProviderRunners(input: CreateScaleUpRunnersInput): Promise { @@ -174,6 +194,9 @@ async function createTestProviderRunners(input: CreateScaleUpRunnersInput + process.env.EC2_INSTANCE_ARN_PREFIX ? `${process.env.EC2_INSTANCE_ARN_PREFIX}${runnerId}` : undefined, getRunnerConfigMetadata: (runnerId) => [{ key: 'RunnerId', value: runnerId }], }, ); @@ -195,10 +218,15 @@ beforeEach(() => { setDefaults(); mockGetRunnerConfigStore.mockReturnValue(mockRunnerConfigStore); mockGetRunnerGroupCacheStore.mockReturnValue(mockRunnerGroupCacheStore); + mockGetRunnerStateStore.mockReturnValue(undefined); mockRunnerConfigCreate.mockResolvedValue(); mockRunnerConfigHouseKeeper.mockResolvedValue(); mockRunnerGroupCacheGet.mockResolvedValue(1); mockRunnerGroupCacheCreate.mockResolvedValue(); + vi.mocked(mockRunnerStateStore.create).mockResolvedValue(); + vi.mocked(mockRunnerStateStore.recordGitHubIdentity).mockResolvedValue(); + vi.mocked(mockRunnerStateStore.activate).mockResolvedValue(); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([]); defaultOctokitMockImpl(); mockedResolveCapability.mockReturnValue(() => mockComputeProvider); @@ -213,7 +241,7 @@ beforeEach(() => { runnerType: input.runnerType, runnerOwner: input.runnerOwner, }) - ).length; + ).map((runner) => runner.id); }); mockCreateRunners.mockImplementation(createTestProviderRunners); @@ -648,7 +676,7 @@ describe('scaleUp with GHES', () => { runnerLabels: labels.filter((label) => label.startsWith('ghr-')), state: testProviderState, })); - mockGetCurrentRunners.mockResolvedValue(0); + mockGetCurrentRunners.mockResolvedValue([]); mockCreateRunners.mockResolvedValue({ instances: ['runner'], retryableErrorCount: 0, @@ -2071,6 +2099,247 @@ describe('compute provider selection', () => { }); }); +describe('durable runner inventory', () => { + it('keeps the provider count path when durable state storage is unavailable', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '1'; + + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockGetCurrentRunners).toHaveBeenCalled(); + expect(mockRunnerStateStore.list).not.toHaveBeenCalled(); + expect(mockCreateRunners).not.toHaveBeenCalled(); + }); + + it('keeps the legacy runner config record shape when no access scope is configured', async () => { + process.env.ENABLE_EPHEMERAL_RUNNERS = 'false'; + + await scaleUpModule.scaleUp(TEST_DATA); + + const [record] = mockRunnerConfigCreate.mock.calls[0]; + expect(record).toEqual({ + runnerId: 'i-12345', + value: expect.any(String), + }); + expect(record).not.toHaveProperty('accessScope'); + expect(mockRunnerStateStore.create).not.toHaveBeenCalled(); + }); + + it('preserves all-or-nothing config writes when durable state storage is unavailable', async () => { + mockRunnerConfigCreate.mockImplementation(async (record) => { + if (record.runnerId === 'i-failed') throw new Error('config write failed'); + }); + + await expect( + createStartRunnerConfig( + nonJitRunnerConfig(), + ['i-success-before', 'i-failed', 'i-not-attempted'], + mockOctokit as unknown as Octokit, + ), + ).rejects.toThrow('config write failed'); + + expect(mockRunnerConfigCreate.mock.calls.map(([record]) => record.runnerId)).toEqual([ + 'i-success-before', + 'i-failed', + ]); + expect(mockRunnerStateStore.create).not.toHaveBeenCalled(); + }); + + it('isolates non-JIT config failures per runner when durable state storage is enabled', async () => { + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + mockRunnerConfigCreate.mockImplementation(async (record) => { + if (record.runnerId === 'i-failed') throw new Error('config write failed'); + }); + + await expect( + createStartRunnerConfig( + nonJitRunnerConfig(), + ['i-success-before', 'i-failed', 'i-success-after'], + mockOctokit as unknown as Octokit, + { computeProvider: 'ec2' }, + ), + ).resolves.toEqual(['i-failed']); + + expect(vi.mocked(mockRunnerStateStore.create).mock.calls.map(([record]) => record.runnerId)).toEqual([ + 'i-success-before', + 'i-failed', + 'i-success-after', + ]); + expect(mockRunnerConfigCreate.mock.calls.map(([record]) => record.runnerId)).toEqual([ + 'i-success-before', + 'i-failed', + 'i-success-after', + ]); + expect(vi.mocked(mockRunnerStateStore.activate).mock.calls.map(([runnerId]) => runnerId)).toEqual([ + 'i-success-before', + 'i-success-after', + ]); + }); + + it('counts the union of stored and provider-discovered resources for maximum headroom', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '3'; + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([ + runnerStateRecord('i-shared'), + runnerStateRecord('i-stored-only'), + ]); + mockListRunners.mockResolvedValue([{ id: 'i-shared' }, { id: 'i-provider-only' }]); + + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerStateStore.list).toHaveBeenCalledWith({ computeProvider: 'ec2' }); + expect(mockGetCurrentRunners).toHaveBeenCalled(); + expect(mockCreateRunners).not.toHaveBeenCalled(); + }); + + it('keeps provider discovery as recovery when a resource has no state record', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '3'; + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + vi.mocked(mockRunnerStateStore.list).mockResolvedValue([]); + mockListRunners.mockResolvedValue([{ id: 'i-1' }, { id: 'i-2' }, { id: 'i-3' }]); + + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockCreateRunners).not.toHaveBeenCalled(); + }); + + it('creates provisioning state before config and activates it after success', async () => { + process.env.ENABLE_EPHEMERAL_RUNNERS = 'false'; + process.env.EC2_INSTANCE_ARN_PREFIX = 'arn:aws:ec2:eu-west-1:123456789012:instance/'; + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerStateStore.create).toHaveBeenCalledWith({ + runnerId: 'i-12345', + computeProvider: 'ec2', + computeResourceId: 'i-12345', + runnerOwner: TEST_DATA_SINGLE.repositoryOwner, + runnerType: 'Org', + runnerName: undefined, + runnerLabels: undefined, + metadata: [{ key: 'RunnerId', value: 'i-12345' }], + }); + expect(mockRunnerStateStore.activate).toHaveBeenCalledWith('i-12345', { + metadata: [{ key: 'RunnerId', value: 'i-12345' }], + }); + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { + runnerId: 'i-12345', + value: expect.any(String), + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-12345', + }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); + expect(vi.mocked(mockRunnerStateStore.create).mock.invocationCallOrder[0]).toBeLessThan( + mockRunnerConfigCreate.mock.invocationCallOrder[0], + ); + expect(mockRunnerConfigCreate.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockRunnerStateStore.activate).mock.invocationCallOrder[0], + ); + }); + + it('records JIT GitHub identity while provisioning before a config write failure', async () => { + process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; + process.env.RUNNER_NAME_PREFIX = 'unit-test-'; + process.env.RUNNER_LABELS = 'label1,label2'; + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + mockRunnerConfigCreate.mockRejectedValueOnce(new Error('config write failed')); + + await expect(scaleUpModule.scaleUp(TEST_DATA)).resolves.toEqual(['foobar']); + + expect(mockRunnerStateStore.create).toHaveBeenCalledWith( + expect.objectContaining({ + runnerId: 'i-12345', + runnerName: 'unit-test-i-12345', + runnerLabels: ['label1', 'label2'], + }), + ); + expect(mockRunnerStateStore.recordGitHubIdentity).toHaveBeenCalledWith('i-12345', { + githubRunnerId: '9876543210', + runnerLabels: ['label1', 'label2'], + runnerName: 'unit-test-i-12345', + metadata: [{ key: 'RunnerId', value: 'i-12345' }], + }); + expect(vi.mocked(mockRunnerStateStore.recordGitHubIdentity).mock.invocationCallOrder[0]).toBeLessThan( + mockRunnerConfigCreate.mock.invocationCallOrder[0], + ); + expect(mockRunnerStateStore.activate).not.toHaveBeenCalled(); + }); + + it('de-registers an org JIT runner when its GitHub identity cannot be persisted', async () => { + process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; + process.env.RUNNER_NAME_PREFIX = 'unit-test-'; + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + vi.mocked(mockRunnerStateStore.recordGitHubIdentity).mockRejectedValueOnce(new Error('identity write failed')); + + await expect(scaleUpModule.scaleUp(TEST_DATA)).resolves.toEqual(['foobar']); + + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg).toHaveBeenCalledWith({ + org: TEST_DATA_SINGLE.repositoryOwner, + runner_id: 9876543210, + }); + expect(mockRunnerConfigCreate).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.activate).not.toHaveBeenCalled(); + }); + + it('keeps a repo JIT identity-write failure retryable when immediate de-registration also fails', async () => { + process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; + process.env.RUNNER_NAME_PREFIX = 'unit-test-'; + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + vi.mocked(mockRunnerStateStore.recordGitHubIdentity).mockRejectedValueOnce(new Error('identity write failed')); + mockOctokit.actions.deleteSelfHostedRunnerFromRepo.mockRejectedValueOnce(new Error('GitHub delete failed')); + + await expect(scaleUpModule.scaleUp(TEST_DATA)).resolves.toEqual(['foobar']); + + expect(mockOctokit.actions.deleteSelfHostedRunnerFromRepo).toHaveBeenCalledWith({ + owner: TEST_DATA_SINGLE.repositoryOwner, + repo: TEST_DATA_SINGLE.repositoryName, + runner_id: 9876543210, + }); + expect(mockRunnerConfigCreate).not.toHaveBeenCalled(); + expect(mockRunnerStateStore.activate).not.toHaveBeenCalled(); + }); + + it('activates successful JIT state with its GitHub identity, labels, and isolated config scope', async () => { + process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; + process.env.RUNNER_NAME_PREFIX = 'unit-test-'; + process.env.RUNNER_LABELS = 'label1,label2'; + process.env.RUNNER_GROUP_NAME = 'Default'; + process.env.EC2_INSTANCE_ARN_PREFIX = 'arn:aws:ec2:eu-west-1:123456789012:instance/'; + mockGetRunnerStateStore.mockReturnValue(mockRunnerStateStore); + + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockRunnerConfigCreate).toHaveBeenCalledWith( + { + runnerId: 'i-12345', + value: 'TEST_JIT_CONFIG_ORG', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-12345', + }, + { metadata: [{ key: 'RunnerId', value: 'i-12345' }] }, + ); + expect(mockRunnerStateStore.recordGitHubIdentity).toHaveBeenCalledWith('i-12345', { + githubRunnerId: '9876543210', + runnerLabels: ['label1', 'label2'], + runnerName: 'unit-test-i-12345', + metadata: [{ key: 'RunnerId', value: 'i-12345' }], + }); + expect(mockRunnerStateStore.activate).toHaveBeenCalledWith('i-12345', { + githubRunnerId: '9876543210', + runnerLabels: ['label1', 'label2'], + runnerName: 'unit-test-i-12345', + metadata: [{ key: 'RunnerId', value: 'i-12345' }], + }); + expect(vi.mocked(mockRunnerStateStore.recordGitHubIdentity).mock.invocationCallOrder[0]).toBeLessThan( + mockRunnerConfigCreate.mock.invocationCallOrder[0], + ); + expect(mockRunnerConfigCreate.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockRunnerStateStore.activate).mock.invocationCallOrder[0], + ); + }); +}); + describe('Multi-app round-robin', () => { const mockedGetAppCount = vi.mocked(ghAuth.getAppCount); const mockedGetStoredInstallationId = vi.mocked(ghAuth.getStoredInstallationId); @@ -2234,6 +2503,34 @@ function defaultOctokitMockImpl() { mockOctokit.actions.createRegistrationTokenForOrg.mockImplementation(() => mockTokenReturnValue); mockOctokit.actions.createRegistrationTokenForRepo.mockImplementation(() => mockTokenReturnValue); + mockOctokit.actions.deleteSelfHostedRunnerFromOrg.mockResolvedValue({ status: 204 }); + mockOctokit.actions.deleteSelfHostedRunnerFromRepo.mockResolvedValue({ status: 204 }); mockOctokit.apps.getOrgInstallation.mockImplementation(() => mockInstallationIdReturnValueOrgs); mockOctokit.apps.getRepoInstallation.mockImplementation(() => mockInstallationIdReturnValueRepos); } + +function runnerStateRecord(runnerId: string): RunnerStateRecord { + return { + runnerId, + computeProvider: 'ec2', + computeResourceId: runnerId, + runnerOwner: TEST_DATA_SINGLE.repositoryOwner, + runnerType: 'Org', + state: 'active', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +function nonJitRunnerConfig(): CreateGitHubRunnerConfig { + return { + ephemeral: false, + enableJitConfig: false, + runnerLabels: '', + runnerGroup: 'Default', + runnerNamePrefix: '', + runnerOwner: TEST_DATA_SINGLE.repositoryOwner, + runnerType: 'Org', + disableAutoUpdate: false, + }; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index 48733c13c9..3979fb2cc2 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -1,5 +1,6 @@ import { addPersistentContextToChildLogger, createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { getRunnerStateStore } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; import yn from 'yn'; @@ -253,7 +254,13 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise + await computeProvider.getCurrentRunners(runnerLabelResolution.state, { runnerType, runnerOwner }), + ); logger.info('Current runners', { currentRunners, @@ -363,6 +370,31 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise Promise, +): Promise { + const runnerStateStore = getRunnerStateStore(); + if (!runnerStateStore) { + return (await getProviderRunnerIds()).length; + } + + const records = await runnerStateStore.list({ computeProvider }); + const runnerIds = new Set( + records + .filter((record) => record.runnerType === runnerType && record.runnerOwner === runnerOwner) + .map((record) => record.computeResourceId), + ); + // Durable inventory is canonical. Provider discovery remains a conservative + // recovery source for resources launched before their inventory record was written. + for (const runnerId of await getProviderRunnerIds()) { + runnerIds.add(runnerId); + } + return runnerIds.size; +} + function isValidRepoOwnerTypeIfOrgLevelEnabled(payload: ActionRequestMessage, enableOrgLevel: boolean): boolean { return !(enableOrgLevel && payload.repoOwnerType !== 'Organization'); } diff --git a/lambdas/functions/control-plane/src/test/compute-provider-contracts/scale-up.ts b/lambdas/functions/control-plane/src/test/compute-provider-contracts/scale-up.ts index ecab384118..542f01d726 100644 --- a/lambdas/functions/control-plane/src/test/compute-provider-contracts/scale-up.ts +++ b/lambdas/functions/control-plane/src/test/compute-provider-contracts/scale-up.ts @@ -43,7 +43,7 @@ export function defineScaleUpContractTests({ resolveCapability.mockReturnValue(() => provider); vi.mocked(provider.resolveLabelsForRunners).mockResolvedValue({ runnerLabels: [], state }); - vi.mocked(provider.getCurrentRunners).mockResolvedValue(0); + vi.mocked(provider.getCurrentRunners).mockResolvedValue([]); vi.mocked(provider.createRunners).mockResolvedValue(createResult); }); @@ -81,7 +81,7 @@ export function defineScaleUpContractTests({ 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); + vi.mocked(provider.getCurrentRunners).mockResolvedValue(['runner-1']); await scaleUp(createPayloads()); diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts index 6d195d958b..9c78d49153 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-config.ts @@ -141,6 +141,9 @@ async function terminateFailedInstances(instanceIds: string[]): Promise { function createEc2StartRunnerConfigOptions(): StartRunnerConfigOptions { return { + computeProvider: 'ec2', + getRunnerConfigAccessScope: (instanceId) => + process.env.EC2_INSTANCE_ARN_PREFIX ? `${process.env.EC2_INSTANCE_ARN_PREFIX}${instanceId}` : undefined, getRunnerConfigMetadata: (instanceId) => [{ key: 'InstanceId', value: instanceId }], onJitConfigCreated: async (instanceId, metadata) => await tagEc2RunnerMetadata(instanceId, metadata), }; diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts index 9018e08ff9..938195b09e 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts @@ -103,7 +103,7 @@ async function expectCurrentRunners(runnerType: RunnerType, owner: string) { await expect( provider.getCurrentRunners(runnerLabelResolution.state, { runnerType, runnerOwner: owner }), - ).resolves.toBe(1); + ).resolves.toEqual(['i-1234']); expect(mockListRunners).toHaveBeenCalledWith({ environment: 'unit-test-environment', runnerType, @@ -128,6 +128,7 @@ beforeEach(() => { delete process.env.POWERTOOLS_TRACE_ENABLED; delete process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS; delete process.env.USE_DEDICATED_HOST; + delete process.env.EC2_INSTANCE_ARN_PREFIX; mockEC2Client.reset(); mockEC2Client.on(DescribeLaunchTemplateVersionsCommand).resolves({ @@ -182,6 +183,17 @@ describe('scaleUp with GHES', () => { expect(options?.getRunnerConfigMetadata?.('i-12345')).toEqual([{ key: 'InstanceId', value: 'i-12345' }]); }); + it('builds a runner-specific config access scope from the EC2 instance ARN prefix', async () => { + process.env.EC2_INSTANCE_ARN_PREFIX = 'arn:aws:ec2:eu-west-1:123456789012:instance/'; + + await createProviderRunners(); + + const [, , , options] = mockCreateStartRunnerConfig.mock.calls[0]; + expect(options?.getRunnerConfigAccessScope?.('i-12345')).toBe( + 'arn:aws:ec2:eu-west-1:123456789012:instance/i-12345', + ); + }); + it('chunks comma-joined GitHub runner labels by the EC2 tag value max length', async () => { const runnerLabels = ['a'.repeat(EC2_TAG_VALUE_MAX_LENGTH), 'b'].join(','); await createProviderRunners({ baseRunnerLabels: runnerLabels }); @@ -237,6 +249,19 @@ describe('scaleUp with GHES', () => { }); expect(mockTerminateRunner).toHaveBeenCalledWith('i-12345'); }); + + it('terminates only runner instances whose configuration failed', async () => { + mockCreateRunner.mockResolvedValueOnce(createRunnerResult(['i-success-before', 'i-failed', 'i-success-after'])); + mockCreateStartRunnerConfig.mockResolvedValueOnce(['i-failed']); + + await expect(createProviderRunners()).resolves.toEqual({ + instances: ['i-success-before', 'i-success-after'], + retryableErrorCount: 1, + nonRetryableErrorCount: 0, + }); + expect(mockTerminateRunner).toHaveBeenCalledTimes(1); + expect(mockTerminateRunner).toHaveBeenCalledWith('i-failed'); + }); }); describe('Dynamic EC2 Configuration', () => { diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts index d72edaf7a4..0553595c0e 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts @@ -58,8 +58,10 @@ async function resolveEc2LabelsForRunners(messageLabels: string[]): Promise { - return (await listEC2Runners({ environment: process.env.ENVIRONMENT, runnerType, runnerOwner })).length; +): Promise { + return (await listEC2Runners({ environment: process.env.ENVIRONMENT, runnerType, runnerOwner })).map( + (runner) => runner.id, + ); } async function createEc2ScaleUpRunners( diff --git a/lambdas/libs/compute-providers/aws/ec2/src/environment.d.ts b/lambdas/libs/compute-providers/aws/ec2/src/environment.d.ts index 71ee01ff2f..189b161925 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/environment.d.ts @@ -16,6 +16,7 @@ declare global { | 'capacity-optimized' | 'capacity-optimized-prioritized' | 'prioritized'; + EC2_INSTANCE_ARN_PREFIX: string | undefined; SCALE_ERRORS: string; } } diff --git a/lambdas/libs/compute-providers/core/index.ts b/lambdas/libs/compute-providers/core/index.ts index 908b67fb54..8357e294f9 100644 --- a/lambdas/libs/compute-providers/core/index.ts +++ b/lambdas/libs/compute-providers/core/index.ts @@ -29,6 +29,10 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { + /** Compute provider that owns the runner IDs. Used by provider-neutral runner inventory. */ + computeProvider?: ComputeProviderType; + /** Access scope used by secret stores to isolate one runner's bootstrap configuration. */ + getRunnerConfigAccessScope?: (runnerId: string) => string | undefined; getRunnerConfigMetadata?: (runnerId: string) => { key: string; value: string }[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; } @@ -65,7 +69,8 @@ export interface CreateRunnerResult { export interface ScaleUpComputeProvider extends ComputeProvider { resolveLabelsForRunners(messageLabels: string[]): Promise>; - getCurrentRunners(state: TState, input: CurrentRunnersInput): Promise; + /** Compute resource IDs currently owned by this runner entry. */ + getCurrentRunners(state: TState, input: CurrentRunnersInput): Promise; createRunners(input: CreateScaleUpRunnersInput): Promise; } @@ -113,7 +118,7 @@ export interface CreatePoolRunnersInput { githubInstallationClient: Octokit; } -export interface PoolComputeProvider extends ComputeProvider { +export interface PoolComputeProvider extends ComputeProvider { listRunners(input: ListPoolRunnersInput): Promise; countAvailableRunners( runners: TRunner[], diff --git a/lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts b/lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts new file mode 100644 index 0000000000..f22fde3269 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts @@ -0,0 +1,37 @@ +import { GetItemCommand } from '@aws-sdk/client-dynamodb'; + +import { getDynamoDbClient } from './client'; +import { ID_ATTRIBUTE, SCOPE_ATTRIBUTE, VALUE_ATTRIBUTE } from './keys'; + +export async function getDurableConfigValue( + tableName: string, + scope: string, + id: string, + description: string, +): Promise { + const result = await getDynamoDbClient().send( + new GetItemCommand({ + TableName: tableName, + Key: { + [SCOPE_ATTRIBUTE]: { S: scope }, + [ID_ATTRIBUTE]: { S: id }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': VALUE_ATTRIBUTE, + }, + }), + ); + + if (!result.Item) { + throw new Error(`${description} item '${scope}/${id}' was not found`); + } + + const value = result.Item[VALUE_ATTRIBUTE]?.S; + if (value === undefined) { + throw new Error(`${description} item '${scope}/${id}' does not contain a string value`); + } + + return value; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts b/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts index 80f6cd76b4..59be4dbf10 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts @@ -3,10 +3,10 @@ export {}; declare global { namespace NodeJS { interface ProcessEnv { - RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX?: string; RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME?: string; - RUNNER_CONFIG_DYNAMODB_TABLE_NAME?: string; - RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX?: string; + RUNNER_CONFIG_DYNAMODB_ENTRY_ID?: string; + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME?: string; + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS?: string; RUNNER_CONFIG_DYNAMODB_TTL_SECONDS?: string; } } diff --git a/lambdas/libs/storage-providers/aws/dynamodb/environment.ts b/lambdas/libs/storage-providers/aws/dynamodb/environment.ts index 008df063f7..fc0a394139 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/environment.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/environment.ts @@ -1,8 +1,8 @@ type DynamoDbEnvironmentVariable = - | 'RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX' | 'RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME' - | 'RUNNER_CONFIG_DYNAMODB_TABLE_NAME' - | 'RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX' + | 'RUNNER_CONFIG_DYNAMODB_ENTRY_ID' + | 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME' + | 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS' | 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'; export function requiredEnvironmentValue(name: DynamoDbEnvironmentVariable): string { diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.test.ts new file mode 100644 index 0000000000..500ad90749 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.test.ts @@ -0,0 +1,103 @@ +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbGitHubAppCredentialsStore } from './github-app-credentials-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb GitHub App credentials store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + }); + + it('strongly reads and decodes an ordered credential array', async () => { + const value = JSON.stringify([ + { appId: 123, privateKeyBase64: Buffer.from('primary\\nkey').toString('base64') }, + { + appId: 456, + privateKeyBase64: Buffer.from('additional-key').toString('base64'), + installationId: 789, + }, + ]); + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: value } } }); + const store = createAwsDynamoDbGitHubAppCredentialsStore(); + + await expect(store.get()).resolves.toEqual([ + { appId: 123, privateKey: 'primary\nkey', installationId: undefined }, + { appId: 456, privateKey: 'additional-key', installationId: 789 }, + ]); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + scope: { S: 'global#github-app' }, + id: { S: 'github-app-credentials' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { '#value': 'value' }, + }); + }); + + it.each([ + ['not-json', 'contains invalid JSON'], + ['[]', 'must contain a non-empty array'], + [JSON.stringify([null]), 'credential at index 0 has an invalid stored value'], + [JSON.stringify([{ appId: 0, privateKeyBase64: 'a2V5' }]), 'credential at index 0 has an invalid stored value'], + [ + JSON.stringify([{ appId: 1, privateKeyBase64: 'not-base64' }]), + 'credential at index 0 has an invalid stored value', + ], + [ + JSON.stringify([{ appId: 1, privateKeyBase64: 'a2V5', installationId: 1.5 }]), + 'credential at index 0 has an invalid stored value', + ], + ])('rejects malformed stored credentials without returning their value', async (value, message) => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: value } } }); + const store = createAwsDynamoDbGitHubAppCredentialsStore(); + + await expect(store.get()).rejects.toThrow(message); + }); + + it('rejects a missing credentials item', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + + await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toThrow( + "GitHub App credentials item 'global#github-app/github-app-credentials' was not found", + ); + }); + + it('rejects a non-string credentials value', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { L: [] } } }); + + await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toThrow( + "GitHub App credentials item 'global#github-app/github-app-credentials' does not contain a string value", + ); + }); + + it.each([undefined, '', ' '])('requires the durable table name for input %j', (tableName) => { + if (tableName === undefined) { + delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME; + } else { + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = tableName; + } + + expect(() => createAwsDynamoDbGitHubAppCredentialsStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + }); + + it('propagates reads errors without exposing stored credentials', async () => { + const error = new Error('access denied'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + + await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.ts new file mode 100644 index 0000000000..e9b1a56c18 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.ts @@ -0,0 +1,89 @@ +import type { GitHubAppCredential, GitHubAppCredentialsStore } from '../../core'; +import { getDurableConfigValue } from './durable-config'; +import { requiredEnvironmentValue } from './environment'; +import { GITHUB_APP_CREDENTIALS_ID, GITHUB_APP_SCOPE } from './keys'; + +interface StoredGitHubAppCredential { + appId: number; + privateKeyBase64: string; + installationId?: number; +} + +export function createAwsDynamoDbGitHubAppCredentialsStore(): GitHubAppCredentialsStore { + return new AwsDynamoDbGitHubAppCredentialsStore(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME')); +} + +class AwsDynamoDbGitHubAppCredentialsStore implements GitHubAppCredentialsStore { + constructor(private readonly tableName: string) {} + + async get(): Promise { + const value = await getDurableConfigValue( + this.tableName, + GITHUB_APP_SCOPE, + GITHUB_APP_CREDENTIALS_ID, + 'GitHub App credentials', + ); + const credentials = parseCredentials(value); + + return credentials.map((credential) => ({ + appId: credential.appId, + privateKey: decodePrivateKey(credential.privateKeyBase64), + installationId: credential.installationId, + })); + } +} + +function parseCredentials(value: string): StoredGitHubAppCredential[] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('GitHub App credentials item contains invalid JSON'); + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('GitHub App credentials item must contain a non-empty array'); + } + + return parsed.map((credential, index) => parseCredential(credential, index)); +} + +function parseCredential(value: unknown, index: number): StoredGitHubAppCredential { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidCredential(index); + } + + const credential = value as Record; + if (!isPositiveSafeInteger(credential.appId) || !isValidBase64(credential.privateKeyBase64)) { + throw invalidCredential(index); + } + if (credential.installationId !== undefined && !isPositiveSafeInteger(credential.installationId)) { + throw invalidCredential(index); + } + + return { + appId: credential.appId, + privateKeyBase64: credential.privateKeyBase64, + installationId: credential.installationId, + }; +} + +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function isValidBase64(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0 || value.length % 4 !== 0) { + return false; + } + + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value); +} + +function decodePrivateKey(privateKeyBase64: string): string { + return Buffer.from(privateKeyBase64, 'base64').toString().replace(/\\n/g, '\n'); +} + +function invalidCredential(index: number): Error { + return new Error(`GitHub App credential at index ${index} has an invalid stored value`); +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.test.ts new file mode 100644 index 0000000000..c66272c2c7 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.test.ts @@ -0,0 +1,74 @@ +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbGitHubWebhookSecretStore } from './github-webhook-secret-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb GitHub webhook secret store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + }); + + it('strongly reads the global webhook secret', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: 'webhook-secret' } } }); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).resolves.toBe('webhook-secret'); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + scope: { S: 'global#webhook' }, + id: { S: 'github-webhook-secret' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { '#value': 'value' }, + }); + }); + + it('leaves empty-value validation to the webhook config loader', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '' } } }); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).resolves.toBe(''); + }); + + it('rejects a missing secret item without logging or returning a value', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toThrow( + "GitHub webhook secret item 'global#webhook/github-webhook-secret' was not found", + ); + }); + + it('rejects a non-string secret item', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { B: new Uint8Array() } } }); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toThrow( + "GitHub webhook secret item 'global#webhook/github-webhook-secret' does not contain a string value", + ); + }); + + it('requires the durable table name before reading', () => { + delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME; + + expect(() => createAwsDynamoDbGitHubWebhookSecretStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it('propagates read errors without handling the secret value', async () => { + const error = new Error('access denied'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.ts new file mode 100644 index 0000000000..6fbf8f81b4 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.ts @@ -0,0 +1,21 @@ +import type { GitHubWebhookSecretStore } from '../../core'; +import { getDurableConfigValue } from './durable-config'; +import { requiredEnvironmentValue } from './environment'; +import { GITHUB_WEBHOOK_SCOPE, GITHUB_WEBHOOK_SECRET_ID } from './keys'; + +export function createAwsDynamoDbGitHubWebhookSecretStore(): GitHubWebhookSecretStore { + return new AwsDynamoDbGitHubWebhookSecretStore(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME')); +} + +class AwsDynamoDbGitHubWebhookSecretStore implements GitHubWebhookSecretStore { + constructor(private readonly tableName: string) {} + + async get(): Promise { + return await getDurableConfigValue( + this.tableName, + GITHUB_WEBHOOK_SCOPE, + GITHUB_WEBHOOK_SECRET_ID, + 'GitHub webhook secret', + ); + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/keys.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/keys.test.ts new file mode 100644 index 0000000000..ac651df069 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/keys.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { + GITHUB_APP_CREDENTIALS_ID, + GITHUB_APP_SCOPE, + GITHUB_WEBHOOK_SCOPE, + GITHUB_WEBHOOK_SECRET_ID, + RUNNER_BOOTSTRAP_CONFIG_ID, + RUNNER_CONFIG_ID, + RUNNER_MATCHER_CONFIG_ID, + RUNNER_MATCHER_SCOPE, + runnerBootstrapScope, + runnerGroupId, + runnerGroupScope, + runnerStateId, + runnerStateScope, +} from './keys'; + +describe('aws_dynamodb storage keys', () => { + it('isolates whole-deployment durable records by capability', () => { + expect({ scope: GITHUB_APP_SCOPE, id: GITHUB_APP_CREDENTIALS_ID }).toEqual({ + scope: 'global#github-app', + id: 'github-app-credentials', + }); + expect({ scope: GITHUB_WEBHOOK_SCOPE, id: GITHUB_WEBHOOK_SECRET_ID }).toEqual({ + scope: 'global#webhook', + id: 'github-webhook-secret', + }); + expect({ scope: RUNNER_MATCHER_SCOPE, id: RUNNER_MATCHER_CONFIG_ID }).toEqual({ + scope: 'global#matcher', + id: 'runner-matcher-config', + }); + }); + + it('isolates entry records by access boundary', () => { + expect({ scope: runnerBootstrapScope('linux-x64'), id: RUNNER_BOOTSTRAP_CONFIG_ID }).toEqual({ + scope: 'entry#linux-x64#bootstrap', + id: 'runner-config', + }); + expect({ scope: runnerGroupScope('linux-x64'), id: runnerGroupId('Default') }).toEqual({ + scope: 'entry#linux-x64#runner-group', + id: 'runner-group#Default', + }); + expect(RUNNER_CONFIG_ID).toBe('config'); + expect({ scope: runnerStateScope('linux-x64'), id: runnerStateId('runner-123') }).toEqual({ + scope: 'entry#linux-x64#runner-state', + id: 'runner#runner-123', + }); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/keys.ts b/lambdas/libs/storage-providers/aws/dynamodb/keys.ts new file mode 100644 index 0000000000..cef92af6bb --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/keys.ts @@ -0,0 +1,34 @@ +export const SCOPE_ATTRIBUTE = 'scope'; +export const ID_ATTRIBUTE = 'id'; +export const VALUE_ATTRIBUTE = 'value'; +export const EXPIRES_AT_ATTRIBUTE = 'expires_at'; + +export const GITHUB_APP_SCOPE = 'global#github-app'; +export const GITHUB_WEBHOOK_SCOPE = 'global#webhook'; +export const RUNNER_MATCHER_SCOPE = 'global#matcher'; + +export const GITHUB_APP_CREDENTIALS_ID = 'github-app-credentials'; +export const GITHUB_WEBHOOK_SECRET_ID = 'github-webhook-secret'; +export const RUNNER_MATCHER_CONFIG_ID = 'runner-matcher-config'; +export const RUNNER_BOOTSTRAP_CONFIG_ID = 'runner-config'; +export const RUNNER_CONFIG_ID = 'config'; + +export function runnerBootstrapScope(entryId: string): string { + return `entry#${entryId}#bootstrap`; +} + +export function runnerGroupScope(entryId: string): string { + return `entry#${entryId}#runner-group`; +} + +export function runnerStateScope(entryId: string): string { + return `entry#${entryId}#runner-state`; +} + +export function runnerStateId(runnerId: string): string { + return `runner#${runnerId}`; +} + +export function runnerGroupId(runnerGroupName: string): string { + return `runner-group#${runnerGroupName}`; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts index 0801ba666f..1afb6c7fe9 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts @@ -15,8 +15,8 @@ describe('aws_dynamodb runner config store', () => { resetDynamoDbClient(); process.env = { ...cleanEnv }; process.env.AWS_REGION = 'eu-west-1'; - process.env.RUNNER_CONFIG_DYNAMODB_TABLE_NAME = 'runner-config'; - process.env.RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX = 'tokens#'; + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME = 'runner-state'; + delete process.env.RUNNER_CONFIG_DYNAMODB_ENTRY_ID; process.env.RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = '3600'; vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); @@ -29,18 +29,24 @@ describe('aws_dynamodb runner config store', () => { it('creates an expiring runner config without overwriting an existing record', async () => { const store = createAwsDynamoDbRunnerConfigStore(); - await store.create({ runnerId: 'runner-123', value: 'encoded-jit-config' }); + await store.create({ + runnerId: 'runner-123', + value: 'encoded-jit-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }); expect(store.maxWritesPerSecond).toBeUndefined(); expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { - TableName: 'runner-config', + TableName: 'runner-state', Item: { - id: { S: 'tokens#runner-123' }, + scope: { S: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123' }, + id: { S: 'config' }, value: { S: 'encoded-jit-config' }, expires_at: { N: '1735693200' }, }, - ConditionExpression: 'attribute_not_exists(#id)', + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', ExpressionAttributeNames: { + '#scope': 'scope', '#id': 'id', }, }); @@ -50,7 +56,11 @@ describe('aws_dynamodb runner config store', () => { const store = createAwsDynamoDbRunnerConfigStore(); await store.create( - { runnerId: 'runner-123', value: 'registration-config' }, + { + runnerId: 'runner-123', + value: 'registration-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }, { metadata: [ { key: 'InstanceId', value: 'i-123' }, @@ -71,7 +81,14 @@ describe('aws_dynamodb runner config store', () => { it('does not write metadata for an empty metadata list', async () => { const store = createAwsDynamoDbRunnerConfigStore(); - await store.create({ runnerId: 'runner-123', value: 'registration-config' }, { metadata: [] }); + await store.create( + { + runnerId: 'runner-123', + value: 'registration-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }, + { metadata: [] }, + ); const command = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0]; expect(command.input.Item).not.toHaveProperty('metadata'); @@ -85,18 +102,17 @@ describe('aws_dynamodb runner config store', () => { expect(mockDynamoDbClient.calls()).toHaveLength(0); }); - it.each([ - 'RUNNER_CONFIG_DYNAMODB_TABLE_NAME', - 'RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX', - 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS', - ] as const)('rejects a missing or blank %s', (name) => { - delete process.env[name]; - expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); + it.each(['RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'] as const)( + 'rejects a missing or blank %s', + (name) => { + delete process.env[name]; + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); - process.env[name] = ' '; - expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); - expect(mockDynamoDbClient.calls()).toHaveLength(0); - }); + process.env[name] = ' '; + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }, + ); it.each(['0', '-1', '1.5', 'not-a-number', '9007199254740992'])('rejects invalid TTL seconds %j', (ttlSeconds) => { process.env.RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = ttlSeconds; @@ -107,11 +123,26 @@ describe('aws_dynamodb runner config store', () => { expect(mockDynamoDbClient.calls()).toHaveLength(0); }); + it.each([undefined, '', ' '])('rejects invalid access scope %j before writing', async (accessScope) => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await expect(store.create({ runnerId: 'runner-123', value: 'sensitive-config', accessScope })).rejects.toThrow( + "Runner config field 'accessScope' must be a non-empty string for aws_dynamodb", + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + it('propagates DynamoDB write errors without handling the stored value', async () => { const error = new Error('conditional request failed'); mockDynamoDbClient.on(PutItemCommand).rejects(error); const store = createAwsDynamoDbRunnerConfigStore(); - await expect(store.create({ runnerId: 'runner-123', value: 'sensitive-config' })).rejects.toBe(error); + await expect( + store.create({ + runnerId: 'runner-123', + value: 'sensitive-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }), + ).rejects.toBe(error); }); }); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts index 5d4baf2612..79de2b6b71 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts @@ -3,22 +3,18 @@ import { PutItemCommand, type AttributeValue } from '@aws-sdk/client-dynamodb'; import type { RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore } from '../../core'; import { getDynamoDbClient } from './client'; import { positiveIntegerEnvironmentValue, requiredEnvironmentValue } from './environment'; +import { EXPIRES_AT_ATTRIBUTE, ID_ATTRIBUTE, RUNNER_CONFIG_ID, SCOPE_ATTRIBUTE, VALUE_ATTRIBUTE } from './keys'; -const ID_ATTRIBUTE = 'id'; -const VALUE_ATTRIBUTE = 'value'; -const EXPIRES_AT_ATTRIBUTE = 'expires_at'; const METADATA_ATTRIBUTE = 'metadata'; interface AwsDynamoDbRunnerConfigStoreConfig { tableName: string; - tokenKeyPrefix: string; ttlSeconds: number; } export function createAwsDynamoDbRunnerConfigStore(): RunnerConfigStore { return new AwsDynamoDbRunnerConfigStore({ - tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_TABLE_NAME'), - tokenKeyPrefix: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_TOKEN_KEY_PREFIX'), + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME'), ttlSeconds: positiveIntegerEnvironmentValue('RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'), }); } @@ -27,8 +23,13 @@ class AwsDynamoDbRunnerConfigStore implements RunnerConfigStore { constructor(private readonly config: AwsDynamoDbRunnerConfigStoreConfig) {} async create(record: RunnerConfigRecord, options: { metadata?: RunnerConfigMetadata[] } = {}): Promise { + if (typeof record.accessScope !== 'string' || record.accessScope.trim() === '') { + throw new Error("Runner config field 'accessScope' must be a non-empty string for aws_dynamodb"); + } + const item: Record = { - [ID_ATTRIBUTE]: { S: `${this.config.tokenKeyPrefix}${record.runnerId}` }, + [SCOPE_ATTRIBUTE]: { S: record.accessScope }, + [ID_ATTRIBUTE]: { S: RUNNER_CONFIG_ID }, [VALUE_ATTRIBUTE]: { S: record.value }, [EXPIRES_AT_ATTRIBUTE]: { N: (Math.floor(Date.now() / 1000) + this.config.ttlSeconds).toString(), @@ -50,8 +51,9 @@ class AwsDynamoDbRunnerConfigStore implements RunnerConfigStore { new PutItemCommand({ TableName: this.config.tableName, Item: item, - ConditionExpression: 'attribute_not_exists(#id)', + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, '#id': ID_ATTRIBUTE, }, }), diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts index 2bb335874b..2fbac5b69d 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts @@ -16,7 +16,7 @@ describe('aws_dynamodb runner group cache store', () => { process.env = { ...cleanEnv }; process.env.AWS_REGION = 'eu-west-1'; process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; - process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX = 'config#'; + process.env.RUNNER_CONFIG_DYNAMODB_ENTRY_ID = 'linux-x64'; }); it('gets a runner group id with a strongly consistent projected read', async () => { @@ -27,7 +27,8 @@ describe('aws_dynamodb runner group cache store', () => { expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { TableName: 'runner-configuration', Key: { - id: { S: 'config#runner-group#Default' }, + scope: { S: 'entry#linux-x64#runner-group' }, + id: { S: 'runner-group#Default' }, }, ConsistentRead: true, ProjectionExpression: '#value', @@ -53,7 +54,7 @@ describe('aws_dynamodb runner group cache store', () => { const store = createAwsDynamoDbRunnerGroupCacheStore(); await expect(store.get('Default')).rejects.toThrow( - "Runner group cache item 'config#runner-group#Default' has an invalid value", + "Runner group cache item 'entry#linux-x64#runner-group/runner-group#Default' has an invalid value", ); }); @@ -65,17 +66,19 @@ describe('aws_dynamodb runner group cache store', () => { expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { TableName: 'runner-configuration', Item: { - id: { S: 'config#runner-group#Default' }, + scope: { S: 'entry#linux-x64#runner-group' }, + id: { S: 'runner-group#Default' }, value: { S: '42' }, }, - ConditionExpression: 'attribute_not_exists(#id)', + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', ExpressionAttributeNames: { + '#scope': 'scope', '#id': 'id', }, }); }); - it.each(['RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'] as const)( + it.each(['RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_ENTRY_ID'] as const)( 'rejects a missing or blank %s', (name) => { delete process.env[name]; diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts index 4ac2b3587e..a90a79e46e 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts @@ -3,20 +3,23 @@ import { GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'; import type { RunnerGroupCacheRecord, RunnerGroupCacheStore } from '../../core'; import { getDynamoDbClient } from './client'; import { requiredEnvironmentValue } from './environment'; - -const ID_ATTRIBUTE = 'id'; -const VALUE_ATTRIBUTE = 'value'; -const RUNNER_GROUP_KEY = 'runner-group#'; +import { + ID_ATTRIBUTE, + runnerGroupId as runnerGroupItemId, + runnerGroupScope, + SCOPE_ATTRIBUTE, + VALUE_ATTRIBUTE, +} from './keys'; interface AwsDynamoDbRunnerGroupCacheStoreConfig { tableName: string; - configKeyPrefix: string; + scope: string; } export function createAwsDynamoDbRunnerGroupCacheStore(): RunnerGroupCacheStore { return new AwsDynamoDbRunnerGroupCacheStore({ tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'), - configKeyPrefix: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'), + scope: runnerGroupScope(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_ENTRY_ID')), }); } @@ -24,11 +27,12 @@ class AwsDynamoDbRunnerGroupCacheStore implements RunnerGroupCacheStore { constructor(private readonly config: AwsDynamoDbRunnerGroupCacheStoreConfig) {} async get(runnerGroupName: string): Promise { - const id = this.itemId(runnerGroupName); + const id = runnerGroupItemId(runnerGroupName); const result = await getDynamoDbClient().send( new GetItemCommand({ TableName: this.config.tableName, Key: { + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, [ID_ATTRIBUTE]: { S: id }, }, ConsistentRead: true, @@ -45,12 +49,12 @@ class AwsDynamoDbRunnerGroupCacheStore implements RunnerGroupCacheStore { const value = result.Item[VALUE_ATTRIBUTE]?.S; if (value === undefined || !/^\d+$/.test(value)) { - throw new Error(`Runner group cache item '${id}' has an invalid value`); + throw new Error(`Runner group cache item '${this.config.scope}/${id}' has an invalid value`); } const runnerGroupId = Number(value); if (!Number.isSafeInteger(runnerGroupId)) { - throw new Error(`Runner group cache item '${id}' has an invalid value`); + throw new Error(`Runner group cache item '${this.config.scope}/${id}' has an invalid value`); } return runnerGroupId; @@ -61,18 +65,16 @@ class AwsDynamoDbRunnerGroupCacheStore implements RunnerGroupCacheStore { new PutItemCommand({ TableName: this.config.tableName, Item: { - [ID_ATTRIBUTE]: { S: this.itemId(record.runnerGroupName) }, + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, + [ID_ATTRIBUTE]: { S: runnerGroupItemId(record.runnerGroupName) }, [VALUE_ATTRIBUTE]: { S: record.runnerGroupId.toString() }, }, - ConditionExpression: 'attribute_not_exists(#id)', + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, '#id': ID_ATTRIBUTE, }, }), ); } - - private itemId(runnerGroupName: string): string { - return `${this.config.configKeyPrefix}${RUNNER_GROUP_KEY}${runnerGroupName}`; - } } diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts index a8960e3f56..66e8d07c2e 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts @@ -16,7 +16,6 @@ describe('aws_dynamodb runner matcher config store', () => { process.env = { ...cleanEnv }; process.env.AWS_REGION = 'eu-west-1'; process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; - process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX = 'config#'; }); it('gets the matcher config with a strongly consistent projected read', async () => { @@ -27,7 +26,8 @@ describe('aws_dynamodb runner matcher config store', () => { expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { TableName: 'runner-configuration', Key: { - id: { S: 'config#runner-matcher-config' }, + scope: { S: 'global#matcher' }, + id: { S: 'runner-matcher-config' }, }, ConsistentRead: true, ProjectionExpression: '#value', @@ -49,7 +49,7 @@ describe('aws_dynamodb runner matcher config store', () => { const store = createAwsDynamoDbRunnerMatcherConfigStore(); await expect(store.get()).rejects.toThrow( - "Runner matcher config item 'config#runner-matcher-config' was not found", + "Runner matcher config item 'global#matcher/runner-matcher-config' was not found", ); }); @@ -58,21 +58,22 @@ describe('aws_dynamodb runner matcher config store', () => { const store = createAwsDynamoDbRunnerMatcherConfigStore(); await expect(store.get()).rejects.toThrow( - "Runner matcher config item 'config#runner-matcher-config' does not contain a string value", + "Runner matcher config item 'global#matcher/runner-matcher-config' does not contain a string value", ); }); - it.each(['RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'] as const)( - 'rejects a missing or blank %s', - (name) => { - delete process.env[name]; - expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow(`Environment variable ${name} is not set`); - - process.env[name] = ' '; - expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow(`Environment variable ${name} is not set`); - expect(mockDynamoDbClient.calls()).toHaveLength(0); - }, - ); + it('rejects a missing or blank durable table name', () => { + delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME; + expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = ' '; + expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); it('propagates DynamoDB read errors', async () => { const error = new Error('read failed'); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts index dce975b5de..f840345983 100644 --- a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts @@ -1,22 +1,15 @@ -import { GetItemCommand } from '@aws-sdk/client-dynamodb'; - import type { RunnerMatcherConfigStore } from '../../core'; -import { getDynamoDbClient } from './client'; +import { getDurableConfigValue } from './durable-config'; import { requiredEnvironmentValue } from './environment'; - -const ID_ATTRIBUTE = 'id'; -const VALUE_ATTRIBUTE = 'value'; -const RUNNER_MATCHER_CONFIG_KEY = 'runner-matcher-config'; +import { RUNNER_MATCHER_CONFIG_ID, RUNNER_MATCHER_SCOPE } from './keys'; interface AwsDynamoDbRunnerMatcherConfigStoreConfig { tableName: string; - configKeyPrefix: string; } export function createAwsDynamoDbRunnerMatcherConfigStore(): RunnerMatcherConfigStore { return new AwsDynamoDbRunnerMatcherConfigStore({ tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'), - configKeyPrefix: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_KEY_PREFIX'), }); } @@ -24,30 +17,11 @@ class AwsDynamoDbRunnerMatcherConfigStore implements RunnerMatcherConfigStore { constructor(private readonly config: AwsDynamoDbRunnerMatcherConfigStoreConfig) {} async get(): Promise { - const id = `${this.config.configKeyPrefix}${RUNNER_MATCHER_CONFIG_KEY}`; - const result = await getDynamoDbClient().send( - new GetItemCommand({ - TableName: this.config.tableName, - Key: { - [ID_ATTRIBUTE]: { S: id }, - }, - ConsistentRead: true, - ProjectionExpression: '#value', - ExpressionAttributeNames: { - '#value': VALUE_ATTRIBUTE, - }, - }), + return await getDurableConfigValue( + this.config.tableName, + RUNNER_MATCHER_SCOPE, + RUNNER_MATCHER_CONFIG_ID, + 'Runner matcher config', ); - - if (!result.Item) { - throw new Error(`Runner matcher config item '${id}' was not found`); - } - - const value = result.Item[VALUE_ATTRIBUTE]?.S; - if (value === undefined) { - throw new Error(`Runner matcher config item '${id}' does not contain a string value`); - } - - return value; } } diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.test.ts new file mode 100644 index 0000000000..75e74ab526 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.test.ts @@ -0,0 +1,451 @@ +import { + ConditionalCheckFailedException, + DeleteItemCommand, + DynamoDBClient, + PutItemCommand, + QueryCommand, + UpdateItemCommand, + type AttributeValue, +} from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CreateRunnerStateRecord } from '../../core'; +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerStateStore } from './runner-state-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner state store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME = 'runner-state'; + process.env.RUNNER_CONFIG_DYNAMODB_ENTRY_ID = 'linux-x64'; + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS = '86400'; + mockDynamoDbClient.on(PutItemCommand).resolves({}); + mockDynamoDbClient.on(UpdateItemCommand).resolves({}); + mockDynamoDbClient.on(DeleteItemCommand).resolves({}); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a provisioning record without a secret payload or overwrite', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.create(createRecord()); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { + TableName: 'runner-state', + Item: { + scope: { S: 'entry#linux-x64#runner-state' }, + id: { S: 'runner#runner-123' }, + runner_id: { S: 'runner-123' }, + compute_provider: { S: 'aws_ec2' }, + compute_resource_id: { S: 'i-123' }, + runner_name: { S: 'ghr-runner-123' }, + runner_labels: { L: [{ S: 'linux' }, { S: 'x64' }] }, + runner_owner: { S: 'github-aws-runners' }, + runner_type: { S: 'Org' }, + state: { S: 'provisioning' }, + created_at: { S: '2025-01-01T00:00:00.000Z' }, + updated_at: { S: '2025-01-01T00:00:00.000Z' }, + expires_at: { N: '1735776000' }, + metadata: { + L: [{ M: { key: { S: 'Environment' }, value: { S: 'test' } } }], + }, + }, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { '#scope': 'scope', '#id': 'id' }, + }); + const item = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0].input.Item; + expect(item).not.toHaveProperty('value'); + }); + + it('omits optional attributes when provisioning data is not available yet', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + const record = createRecord(); + delete record.runnerName; + delete record.runnerLabels; + delete record.metadata; + + await store.create(record); + + const item = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0].input.Item; + expect(item).not.toHaveProperty('runner_name'); + expect(item).not.toHaveProperty('runner_labels'); + expect(item).not.toHaveProperty('metadata'); + }); + + it('preserves empty provider-neutral metadata values', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.create({ ...createRecord(), metadata: [{ key: 'OptionalTag', value: '' }] }); + + expect(mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0].input.Item?.metadata).toEqual({ + L: [{ M: { key: { S: 'OptionalTag' }, value: { S: '' } } }], + }); + }); + + it('activates only provisioning records and atomically adds GitHub identity metadata', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.activate('runner-123', { + githubRunnerId: '9876', + runnerName: 'jit-runner', + runnerLabels: ['linux', 'arm64'], + metadata: [{ key: 'zone', value: 'eu-west-1a' }], + }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + TableName: 'runner-state', + Key: { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }, + UpdateExpression: + 'SET #state = :state, #updatedAt = :updatedAt, #runnerName = :runnerName, #runnerLabels = :runnerLabels, #githubRunnerId = :githubRunnerId, #metadata = :metadata REMOVE #expiresAt', + ConditionExpression: 'attribute_exists(#scope) AND attribute_exists(#id) AND #state IN (:expectedState0)', + ExpressionAttributeNames: { + '#scope': 'scope', + '#id': 'id', + '#state': 'state', + '#updatedAt': 'updated_at', + '#expiresAt': 'expires_at', + '#runnerName': 'runner_name', + '#runnerLabels': 'runner_labels', + '#githubRunnerId': 'github_runner_id', + '#metadata': 'metadata', + }, + ExpressionAttributeValues: { + ':state': { S: 'active' }, + ':updatedAt': { S: '2025-01-01T00:00:00.000Z' }, + ':runnerName': { S: 'jit-runner' }, + ':runnerLabels': { L: [{ S: 'linux' }, { S: 'arm64' }] }, + ':githubRunnerId': { S: '9876' }, + ':metadata': { L: [{ M: { key: { S: 'zone' }, value: { S: 'eu-west-1a' } } }] }, + ':expectedState0': { S: 'provisioning' }, + }, + }); + }); + + it('records GitHub identity while the runner remains provisioning', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.recordGitHubIdentity('runner-123', { + githubRunnerId: '9876', + runnerName: 'jit-runner', + runnerLabels: ['linux', 'arm64'], + }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + TableName: 'runner-state', + Key: { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }, + UpdateExpression: + 'SET #state = :state, #updatedAt = :updatedAt, #expiresAt = :expiresAt, #runnerName = :runnerName, #runnerLabels = :runnerLabels, #githubRunnerId = :githubRunnerId', + ConditionExpression: 'attribute_exists(#scope) AND attribute_exists(#id) AND #state IN (:expectedState0)', + ExpressionAttributeValues: expect.objectContaining({ + ':state': { S: 'provisioning' }, + ':updatedAt': { S: '2025-01-01T00:00:00.000Z' }, + ':expiresAt': { N: '1735776000' }, + ':runnerName': { S: 'jit-runner' }, + ':runnerLabels': { L: [{ S: 'linux' }, { S: 'arm64' }] }, + ':githubRunnerId': { S: '9876' }, + ':expectedState0': { S: 'provisioning' }, + }), + }); + }); + + it('lists all pages for an entry with a strongly consistent query', async () => { + const lastKey = { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }; + mockDynamoDbClient + .on(QueryCommand) + .resolvesOnce({ Items: [storedRecord()], LastEvaluatedKey: lastKey }) + .resolvesOnce({ Items: [storedRecord({ runnerId: 'runner-456', resourceId: 'vm-456' })] }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.list()).resolves.toEqual([ + expectedRecord(), + expectedRecord({ runnerId: 'runner-456', resourceId: 'vm-456' }), + ]); + const calls = mockDynamoDbClient.commandCalls(QueryCommand); + expect(calls).toHaveLength(2); + expect(calls[0].args[0].input).toMatchObject({ + TableName: 'runner-state', + KeyConditionExpression: '#scope = :scope AND begins_with(#id, :runner)', + ConsistentRead: true, + ExpressionAttributeValues: { + ':scope': { S: 'entry#linux-x64#runner-state' }, + ':runner': { S: 'runner#' }, + }, + }); + expect(calls[1].args[0].input.ExclusiveStartKey).toEqual(lastKey); + }); + + it('filters by compute provider while retaining an entry-scoped key query', async () => { + mockDynamoDbClient.on(QueryCommand).resolves({ Items: [] }); + const store = createAwsDynamoDbRunnerStateStore(); + + await store.list({ computeProvider: 'aws_microvm' }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(QueryCommand, { + FilterExpression: '#computeProvider = :computeProvider', + ExpressionAttributeNames: { + '#scope': 'scope', + '#id': 'id', + '#computeProvider': 'compute_provider', + }, + ExpressionAttributeValues: { + ':scope': { S: 'entry#linux-x64#runner-state' }, + ':runner': { S: 'runner#' }, + ':computeProvider': { S: 'aws_microvm' }, + }, + }); + }); + + it('maps optional fields as absent and rejects corrupt lifecycle state', async () => { + const item = storedRecord(); + delete item.runner_name; + delete item.runner_labels; + delete item.github_runner_id; + delete item.metadata; + mockDynamoDbClient.on(QueryCommand).resolves({ Items: [item] }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.list()).resolves.toEqual([ + expect.objectContaining({ + runnerName: undefined, + runnerLabels: undefined, + githubRunnerId: undefined, + metadata: undefined, + }), + ]); + + item.state = { S: 'unknown' }; + mockDynamoDbClient.on(QueryCommand).resolves({ Items: [item] }); + await expect(store.list()).rejects.toThrow( + "Runner state item 'entry#linux-x64#runner-state/runner#runner-123' has an invalid 'state' attribute", + ); + }); + + it('marks and unmarks orphan state with conditional transitions', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.markOrphan('runner-123'); + await store.unmarkOrphan('runner-123'); + + const calls = mockDynamoDbClient.commandCalls(UpdateItemCommand); + expect(calls[0].args[0].input.ExpressionAttributeValues).toMatchObject({ + ':state': { S: 'orphan' }, + ':expectedState0': { S: 'active' }, + }); + expect(calls[0].args[0].input.UpdateExpression).toContain('REMOVE #expiresAt'); + expect(calls[0].args[0].input.ExpressionAttributeValues).not.toHaveProperty(':expiresAt'); + expect(calls[1].args[0].input.ExpressionAttributeValues).toMatchObject({ + ':state': { S: 'active' }, + ':expectedState0': { S: 'orphan' }, + }); + expect(calls[1].args[0].input.UpdateExpression).toContain('REMOVE #expiresAt'); + expect(calls[1].args[0].input.ExpressionAttributeValues).not.toHaveProperty(':expiresAt'); + }); + + it.each(['provisioning', 'active', 'orphan'] as const)( + 'claims termination and returns the prior %s state', + async (state) => { + mockDynamoDbClient.on(UpdateItemCommand).resolves({ Attributes: { state: { S: state } } }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).resolves.toBe(state); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + ReturnValues: 'ALL_OLD', + ConditionExpression: + 'attribute_exists(#scope) AND attribute_exists(#id) AND (#state IN (:provisioning, :active, :orphan) OR (#state = :terminating AND #updatedAt < :staleBefore))', + ExpressionAttributeValues: expect.objectContaining({ + ':terminating': { S: 'terminating' }, + ':expiresAt': { N: '1735776000' }, + ':provisioning': { S: 'provisioning' }, + ':active': { S: 'active' }, + ':orphan': { S: 'orphan' }, + ':staleBefore': { S: '2024-12-31T23:44:00.000Z' }, + }), + }); + }, + ); + + it('reclaims a stale terminating record on a later invocation without allowing an immediate double claim', async () => { + mockDynamoDbClient + .on(UpdateItemCommand) + .resolvesOnce({ Attributes: { state: { S: 'active' } } }) + .rejectsOnce(new ConditionalCheckFailedException({ $metadata: {}, message: 'lease is still held' })) + .resolvesOnce({ Attributes: { state: { S: 'terminating' } } }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).resolves.toBe('active'); + await expect(store.beginTermination('runner-123')).resolves.toBeUndefined(); + vi.advanceTimersByTime(17 * 60 * 1000); + await expect(store.beginTermination('runner-123')).resolves.toBe('terminating'); + + const reclaimed = mockDynamoDbClient.commandCalls(UpdateItemCommand)[2].args[0].input; + expect(reclaimed.ExpressionAttributeValues?.[':staleBefore']).toEqual({ + S: '2025-01-01T00:01:00.000Z', + }); + }); + + it('returns undefined when another invocation already owns termination', async () => { + mockDynamoDbClient + .on(UpdateItemCommand) + .rejects(new ConditionalCheckFailedException({ $metadata: {}, message: 'condition failed' })); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).resolves.toBeUndefined(); + }); + + it('restores the safety TTL when cancellation returns a runner to provisioning', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.cancelTermination('runner-123', 'provisioning'); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + UpdateExpression: 'SET #state = :state, #updatedAt = :updatedAt, #expiresAt = :expiresAt', + ExpressionAttributeValues: expect.objectContaining({ + ':state': { S: 'provisioning' }, + ':expiresAt': { N: '1735776000' }, + ':expectedState0': { S: 'terminating' }, + }), + }); + }); + + it.each(['active', 'orphan'] as const)('removes the safety TTL when cancellation restores %s', async (state) => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.cancelTermination('runner-123', state); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + UpdateExpression: 'SET #state = :state, #updatedAt = :updatedAt REMOVE #expiresAt', + ExpressionAttributeValues: expect.objectContaining({ + ':state': { S: state }, + ':expectedState0': { S: 'terminating' }, + }), + }); + const values = mockDynamoDbClient.commandCalls(UpdateItemCommand)[0].args[0].input.ExpressionAttributeValues; + expect(values).not.toHaveProperty(':expiresAt'); + }); + + it('deletes only a record whose termination was claimed', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.delete('runner-123'); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(DeleteItemCommand, { + TableName: 'runner-state', + Key: { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }, + ConditionExpression: '#state = :terminating', + ExpressionAttributeNames: { '#state': 'state' }, + ExpressionAttributeValues: { ':terminating': { S: 'terminating' } }, + }); + }); + + it.each([ + 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME', + 'RUNNER_CONFIG_DYNAMODB_ENTRY_ID', + 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS', + ] as const)('rejects missing provider environment %s', (name) => { + delete process.env[name]; + + expect(() => createAwsDynamoDbRunnerStateStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it.each(['0', '-1', '1.5', 'not-a-number'])('rejects invalid state TTL %j', (ttl) => { + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS = ttl; + + expect(() => createAwsDynamoDbRunnerStateStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS must be a positive integer', + ); + }); + + it('validates records before writing', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.create({ ...createRecord(), computeProvider: ' ' })).rejects.toThrow( + "Runner state field 'computeProvider' must be a non-empty string", + ); + await expect(store.create({ ...createRecord(), runnerType: 'Team' as never })).rejects.toThrow( + "Runner state field 'runnerType' must be 'Org' or 'Repo'", + ); + await expect(store.activate('runner-123', { runnerLabels: [''] })).rejects.toThrow( + "Runner state field 'runnerLabels' must be a non-empty string", + ); + await expect(store.recordGitHubIdentity('runner-123', { githubRunnerId: '' })).rejects.toThrow( + "Runner state field 'githubRunnerId' must be a non-empty string", + ); + await expect(store.list({ computeProvider: '' })).rejects.toThrow( + "Runner state field 'computeProvider' must be a non-empty string", + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it('propagates non-conditional lifecycle errors', async () => { + const error = new Error('service unavailable'); + mockDynamoDbClient.on(UpdateItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).rejects.toBe(error); + }); +}); + +function createRecord(): CreateRunnerStateRecord { + return { + runnerId: 'runner-123', + computeProvider: 'aws_ec2', + computeResourceId: 'i-123', + runnerName: 'ghr-runner-123', + runnerLabels: ['linux', 'x64'], + runnerOwner: 'github-aws-runners', + runnerType: 'Org', + metadata: [{ key: 'Environment', value: 'test' }], + }; +} + +function storedRecord(options: { runnerId?: string; resourceId?: string } = {}): Record { + const runnerId = options.runnerId ?? 'runner-123'; + return { + scope: { S: 'entry#linux-x64#runner-state' }, + id: { S: `runner#${runnerId}` }, + runner_id: { S: runnerId }, + compute_provider: { S: 'aws_ec2' }, + compute_resource_id: { S: options.resourceId ?? 'i-123' }, + runner_name: { S: `ghr-${runnerId}` }, + runner_labels: { L: [{ S: 'linux' }, { S: 'x64' }] }, + github_runner_id: { S: '9876' }, + runner_owner: { S: 'github-aws-runners' }, + runner_type: { S: 'Org' }, + state: { S: 'active' }, + created_at: { S: '2025-01-01T00:00:00.000Z' }, + updated_at: { S: '2025-01-01T00:01:00.000Z' }, + metadata: { L: [{ M: { key: { S: 'Environment' }, value: { S: 'test' } } }] }, + }; +} + +function expectedRecord(options: { runnerId?: string; resourceId?: string } = {}) { + const runnerId = options.runnerId ?? 'runner-123'; + return { + runnerId, + computeProvider: 'aws_ec2', + computeResourceId: options.resourceId ?? 'i-123', + runnerName: `ghr-${runnerId}`, + runnerLabels: ['linux', 'x64'], + githubRunnerId: '9876', + runnerOwner: 'github-aws-runners', + runnerType: 'Org', + state: 'active', + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:01:00.000Z', + metadata: [{ key: 'Environment', value: 'test' }], + }; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.ts new file mode 100644 index 0000000000..7263aa93c1 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.ts @@ -0,0 +1,556 @@ +import { + ConditionalCheckFailedException, + DeleteItemCommand, + PutItemCommand, + QueryCommand, + UpdateItemCommand, + type AttributeValue, +} from '@aws-sdk/client-dynamodb'; + +import type { + CreateRunnerStateRecord, + RunnerConfigMetadata, + RunnerGitHubIdentity, + RunnerLifecycleState, + RunnerStateActivation, + RunnerStateFilter, + RunnerStateRecord, + RunnerStateStore, + RunnerType, +} from '../../core'; +import { getDynamoDbClient } from './client'; +import { positiveIntegerEnvironmentValue, requiredEnvironmentValue } from './environment'; +import { EXPIRES_AT_ATTRIBUTE, ID_ATTRIBUTE, runnerStateId, runnerStateScope, SCOPE_ATTRIBUTE } from './keys'; + +const RUNNER_ID_PREFIX = 'runner#'; +const RUNNER_ID_ATTRIBUTE = 'runner_id'; +const COMPUTE_PROVIDER_ATTRIBUTE = 'compute_provider'; +const COMPUTE_RESOURCE_ID_ATTRIBUTE = 'compute_resource_id'; +const RUNNER_NAME_ATTRIBUTE = 'runner_name'; +const RUNNER_LABELS_ATTRIBUTE = 'runner_labels'; +const GITHUB_RUNNER_ID_ATTRIBUTE = 'github_runner_id'; +const RUNNER_OWNER_ATTRIBUTE = 'runner_owner'; +const RUNNER_TYPE_ATTRIBUTE = 'runner_type'; +const STATE_ATTRIBUTE = 'state'; +const CREATED_AT_ATTRIBUTE = 'created_at'; +const UPDATED_AT_ATTRIBUTE = 'updated_at'; +const METADATA_ATTRIBUTE = 'metadata'; +// AWS Lambda can run for at most 15 minutes. One extra minute prevents a second +// invocation from reclaiming a termination while the original can still be running. +const TERMINATION_CLAIM_LEASE_MILLISECONDS = 16 * 60 * 1000; + +interface AwsDynamoDbRunnerStateStoreConfig { + tableName: string; + scope: string; + ttlSeconds: number; +} + +export function createAwsDynamoDbRunnerStateStore(): RunnerStateStore { + return new AwsDynamoDbRunnerStateStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME'), + scope: runnerStateScope(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_ENTRY_ID')), + ttlSeconds: positiveIntegerEnvironmentValue('RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS'), + }); +} + +class AwsDynamoDbRunnerStateStore implements RunnerStateStore { + constructor(private readonly config: AwsDynamoDbRunnerStateStoreConfig) {} + + async create(record: CreateRunnerStateRecord): Promise { + validateCreateRecord(record); + const now = new Date(); + const timestamp = now.toISOString(); + const item: Record = { + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, + [ID_ATTRIBUTE]: { S: runnerStateId(record.runnerId) }, + [RUNNER_ID_ATTRIBUTE]: { S: record.runnerId }, + [COMPUTE_PROVIDER_ATTRIBUTE]: { S: record.computeProvider }, + [COMPUTE_RESOURCE_ID_ATTRIBUTE]: { S: record.computeResourceId }, + [RUNNER_OWNER_ATTRIBUTE]: { S: record.runnerOwner }, + [RUNNER_TYPE_ATTRIBUTE]: { S: record.runnerType }, + [STATE_ATTRIBUTE]: { S: 'provisioning' }, + [CREATED_AT_ATTRIBUTE]: { S: timestamp }, + [UPDATED_AT_ATTRIBUTE]: { S: timestamp }, + [EXPIRES_AT_ATTRIBUTE]: { N: expiresAt(now, this.config.ttlSeconds) }, + }; + + setOptionalString(item, RUNNER_NAME_ATTRIBUTE, record.runnerName); + setOptionalStringList(item, RUNNER_LABELS_ATTRIBUTE, record.runnerLabels); + setMetadata(item, record.metadata); + + await getDynamoDbClient().send( + new PutItemCommand({ + TableName: this.config.tableName, + Item: item, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + }, + }), + ); + } + + async activate(runnerId: string, activation: RunnerStateActivation = {}): Promise { + validateNonEmptyString(runnerId, 'runnerId'); + validateActivation(activation); + await this.transition(runnerId, ['provisioning'], 'active', activation); + } + + async recordGitHubIdentity(runnerId: string, identity: RunnerGitHubIdentity): Promise { + validateNonEmptyString(identity.githubRunnerId, 'githubRunnerId'); + validateActivation(identity); + await this.transition(runnerId, ['provisioning'], 'provisioning', identity); + } + + async list(filter: RunnerStateFilter = {}): Promise { + if (filter.computeProvider !== undefined) { + validateNonEmptyString(filter.computeProvider, 'computeProvider'); + } + + const records: RunnerStateRecord[] = []; + let exclusiveStartKey: Record | undefined; + + do { + const expressionAttributeNames: Record = { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + }; + const expressionAttributeValues: Record = { + ':scope': { S: this.config.scope }, + ':runner': { S: RUNNER_ID_PREFIX }, + }; + + if (filter.computeProvider !== undefined) { + expressionAttributeNames['#computeProvider'] = COMPUTE_PROVIDER_ATTRIBUTE; + expressionAttributeValues[':computeProvider'] = { S: filter.computeProvider }; + } + + const result = await getDynamoDbClient().send( + new QueryCommand({ + TableName: this.config.tableName, + KeyConditionExpression: '#scope = :scope AND begins_with(#id, :runner)', + FilterExpression: filter.computeProvider === undefined ? undefined : '#computeProvider = :computeProvider', + ExpressionAttributeNames: expressionAttributeNames, + ExpressionAttributeValues: expressionAttributeValues, + ConsistentRead: true, + ExclusiveStartKey: exclusiveStartKey, + }), + ); + + for (const item of result.Items ?? []) { + records.push(parseRunnerStateRecord(item, this.config.scope)); + } + exclusiveStartKey = result.LastEvaluatedKey; + } while (exclusiveStartKey !== undefined); + + return records; + } + + async markOrphan(runnerId: string): Promise { + await this.transition(runnerId, ['active'], 'orphan'); + } + + async unmarkOrphan(runnerId: string): Promise { + await this.transition(runnerId, ['orphan'], 'active'); + } + + async beginTermination(runnerId: string): Promise { + validateNonEmptyString(runnerId, 'runnerId'); + const now = new Date(); + const staleBefore = new Date(now.getTime() - TERMINATION_CLAIM_LEASE_MILLISECONDS).toISOString(); + try { + const previous = ( + await getDynamoDbClient().send( + new UpdateItemCommand({ + TableName: this.config.tableName, + Key: this.key(runnerId), + UpdateExpression: 'SET #state = :terminating, #updatedAt = :updatedAt, #expiresAt = :expiresAt', + ConditionExpression: + 'attribute_exists(#scope) AND attribute_exists(#id) AND (#state IN (:provisioning, :active, :orphan) OR (#state = :terminating AND #updatedAt < :staleBefore))', + ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + '#state': STATE_ATTRIBUTE, + '#updatedAt': UPDATED_AT_ATTRIBUTE, + '#expiresAt': EXPIRES_AT_ATTRIBUTE, + }, + ExpressionAttributeValues: { + ':provisioning': { S: 'provisioning' }, + ':active': { S: 'active' }, + ':orphan': { S: 'orphan' }, + ':terminating': { S: 'terminating' }, + ':updatedAt': { S: now.toISOString() }, + ':staleBefore': { S: staleBefore }, + ':expiresAt': { N: expiresAt(now, this.config.ttlSeconds) }, + }, + ReturnValues: 'ALL_OLD', + }), + ) + ).Attributes; + const previousState = previous?.[STATE_ATTRIBUTE]?.S; + if (previousState === undefined || !isRunnerLifecycleState(previousState)) { + throw new Error(`Runner state item '${this.config.scope}/${runnerStateId(runnerId)}' returned no prior state`); + } + return previousState; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return undefined; + } + throw error; + } + } + + async cancelTermination(runnerId: string, restoreState: 'provisioning' | 'active' | 'orphan'): Promise { + if (restoreState !== 'provisioning' && restoreState !== 'active' && restoreState !== 'orphan') { + throw new Error("Runner state field 'restoreState' must be 'provisioning', 'active', or 'orphan'"); + } + await this.transition(runnerId, ['terminating'], restoreState); + } + + async delete(runnerId: string): Promise { + validateNonEmptyString(runnerId, 'runnerId'); + await getDynamoDbClient().send( + new DeleteItemCommand({ + TableName: this.config.tableName, + Key: this.key(runnerId), + ConditionExpression: '#state = :terminating', + ExpressionAttributeNames: { + '#state': STATE_ATTRIBUTE, + }, + ExpressionAttributeValues: { + ':terminating': { S: 'terminating' }, + }, + }), + ); + } + + private async transition( + runnerId: string, + expectedStates: RunnerLifecycleState[], + state: RunnerLifecycleState, + activation: RunnerStateActivation = {}, + returnOldState = false, + ): Promise | undefined> { + validateNonEmptyString(runnerId, 'runnerId'); + const now = new Date(); + const expressionAttributeNames: Record = { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + '#state': STATE_ATTRIBUTE, + '#updatedAt': UPDATED_AT_ATTRIBUTE, + '#expiresAt': EXPIRES_AT_ATTRIBUTE, + }; + const expressionAttributeValues: Record = { + ':state': { S: state }, + ':updatedAt': { S: now.toISOString() }, + }; + const updates = ['#state = :state', '#updatedAt = :updatedAt']; + const hasSafetyTtl = state === 'provisioning' || state === 'terminating'; + if (hasSafetyTtl) { + expressionAttributeValues[':expiresAt'] = { N: expiresAt(now, this.config.ttlSeconds) }; + updates.push('#expiresAt = :expiresAt'); + } + addActivationUpdates(updates, expressionAttributeNames, expressionAttributeValues, activation); + + const expectedStateValues = expectedStates.map((expectedState, index) => { + const placeholder = `:expectedState${index}`; + expressionAttributeValues[placeholder] = { S: expectedState }; + return placeholder; + }); + + const result = await getDynamoDbClient().send( + new UpdateItemCommand({ + TableName: this.config.tableName, + Key: this.key(runnerId), + UpdateExpression: `SET ${updates.join(', ')}${hasSafetyTtl ? '' : ' REMOVE #expiresAt'}`, + ConditionExpression: `attribute_exists(#scope) AND attribute_exists(#id) AND #state IN (${expectedStateValues.join(', ')})`, + ExpressionAttributeNames: expressionAttributeNames, + ExpressionAttributeValues: expressionAttributeValues, + ReturnValues: returnOldState ? 'ALL_OLD' : undefined, + }), + ); + return result.Attributes; + } + + private key(runnerId: string): Record { + return { + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, + [ID_ATTRIBUTE]: { S: runnerStateId(runnerId) }, + }; + } +} + +function parseRunnerStateRecord(item: Record, scope: string): RunnerStateRecord { + const id = requiredStringAttribute(item, ID_ATTRIBUTE, scope); + const runnerType = requiredStringAttribute(item, RUNNER_TYPE_ATTRIBUTE, `${scope}/${id}`); + if (runnerType !== 'Org' && runnerType !== 'Repo') { + throw invalidItem(`${scope}/${id}`, RUNNER_TYPE_ATTRIBUTE); + } + + return { + runnerId: requiredStringAttribute(item, RUNNER_ID_ATTRIBUTE, `${scope}/${id}`), + computeProvider: requiredStringAttribute(item, COMPUTE_PROVIDER_ATTRIBUTE, `${scope}/${id}`), + computeResourceId: requiredStringAttribute(item, COMPUTE_RESOURCE_ID_ATTRIBUTE, `${scope}/${id}`), + runnerName: optionalStringAttribute(item, RUNNER_NAME_ATTRIBUTE, `${scope}/${id}`), + runnerLabels: optionalStringListAttribute(item, RUNNER_LABELS_ATTRIBUTE, `${scope}/${id}`), + githubRunnerId: optionalStringAttribute(item, GITHUB_RUNNER_ID_ATTRIBUTE, `${scope}/${id}`), + runnerOwner: requiredStringAttribute(item, RUNNER_OWNER_ATTRIBUTE, `${scope}/${id}`), + runnerType, + state: requiredLifecycleState(item, `${scope}/${id}`), + createdAt: requiredTimestampAttribute(item, CREATED_AT_ATTRIBUTE, `${scope}/${id}`), + updatedAt: requiredTimestampAttribute(item, UPDATED_AT_ATTRIBUTE, `${scope}/${id}`), + metadata: optionalMetadataAttribute(item, `${scope}/${id}`), + }; +} + +function requiredLifecycleState(item: Record, itemId: string): RunnerLifecycleState { + const state = requiredStringAttribute(item, STATE_ATTRIBUTE, itemId); + if (!isRunnerLifecycleState(state)) { + throw invalidItem(itemId, STATE_ATTRIBUTE); + } + return state; +} + +function isRunnerLifecycleState(value: string): value is RunnerLifecycleState { + return value === 'provisioning' || value === 'active' || value === 'orphan' || value === 'terminating'; +} + +function requiredStringAttribute(item: Record, name: string, itemId: string): string { + const value = item[name]?.S; + if (value === undefined || value.trim() === '') { + throw invalidItem(itemId, name); + } + return value; +} + +function optionalStringAttribute( + item: Record, + name: string, + itemId: string, +): string | undefined { + if (item[name] === undefined) { + return undefined; + } + return requiredStringAttribute(item, name, itemId); +} + +function optionalStringListAttribute( + item: Record, + name: string, + itemId: string, +): string[] | undefined { + const attribute = item[name]; + if (attribute === undefined) { + return undefined; + } + if (!attribute.L) { + throw invalidItem(itemId, name); + } + + return attribute.L.map((value) => { + if (value.S === undefined || value.S.trim() === '') { + throw invalidItem(itemId, name); + } + return value.S; + }); +} + +function requiredTimestampAttribute(item: Record, name: string, itemId: string): string { + const value = requiredStringAttribute(item, name, itemId); + try { + if (new Date(value).toISOString() !== value) { + throw invalidItem(itemId, name); + } + } catch { + throw invalidItem(itemId, name); + } + return value; +} + +function optionalMetadataAttribute( + item: Record, + itemId: string, +): RunnerConfigMetadata[] | undefined { + const metadata = item[METADATA_ATTRIBUTE]; + if (metadata === undefined) { + return undefined; + } + if (!metadata.L) { + throw invalidItem(itemId, METADATA_ATTRIBUTE); + } + + return metadata.L.map((entry) => { + if (!entry.M) { + throw invalidItem(itemId, METADATA_ATTRIBUTE); + } + return { + key: requiredStringAttribute(entry.M, 'key', itemId), + value: requiredMetadataValue(entry.M, itemId), + }; + }); +} + +function validateCreateRecord(record: CreateRunnerStateRecord): void { + validateNonEmptyString(record.runnerId, 'runnerId'); + validateNonEmptyString(record.computeProvider, 'computeProvider'); + validateNonEmptyString(record.computeResourceId, 'computeResourceId'); + validateOptionalString(record.runnerName, 'runnerName'); + validateOptionalStringList(record.runnerLabels, 'runnerLabels'); + validateNonEmptyString(record.runnerOwner, 'runnerOwner'); + validateRunnerType(record.runnerType); + for (const metadata of record.metadata ?? []) { + validateNonEmptyString(metadata.key, 'metadata.key'); + validateString(metadata.value, 'metadata.value'); + } +} + +function validateActivation(activation: RunnerStateActivation): void { + validateOptionalString(activation.runnerName, 'runnerName'); + validateOptionalStringList(activation.runnerLabels, 'runnerLabels'); + validateOptionalString(activation.githubRunnerId, 'githubRunnerId'); + for (const metadata of activation.metadata ?? []) { + validateNonEmptyString(metadata.key, 'metadata.key'); + validateString(metadata.value, 'metadata.value'); + } +} + +function validateRunnerType(value: RunnerType): void { + if (value !== 'Org' && value !== 'Repo') { + throw new Error("Runner state field 'runnerType' must be 'Org' or 'Repo'"); + } +} + +function validateOptionalString(value: string | undefined, name: string): void { + if (value !== undefined) { + validateNonEmptyString(value, name); + } +} + +function validateOptionalStringList(values: string[] | undefined, name: string): void { + for (const value of values ?? []) { + validateNonEmptyString(value, name); + } +} + +function validateNonEmptyString(value: string, name: string): void { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Runner state field '${name}' must be a non-empty string`); + } +} + +function validateString(value: string, name: string): void { + if (typeof value !== 'string') { + throw new Error(`Runner state field '${name}' must be a string`); + } +} + +function requiredMetadataValue(item: Record, itemId: string): string { + const value = item.value?.S; + if (value === undefined) { + throw invalidItem(itemId, METADATA_ATTRIBUTE); + } + return value; +} + +function setOptionalString(item: Record, name: string, value: string | undefined): void { + if (value !== undefined) { + item[name] = { S: value }; + } +} + +function setOptionalStringList(item: Record, name: string, values: string[] | undefined): void { + if (values !== undefined) { + item[name] = { L: values.map((value) => ({ S: value })) }; + } +} + +function addActivationUpdates( + updates: string[], + names: Record, + values: Record, + activation: RunnerStateActivation, +): void { + addOptionalStringUpdate( + updates, + names, + values, + '#runnerName', + ':runnerName', + RUNNER_NAME_ATTRIBUTE, + activation.runnerName, + ); + addOptionalStringListUpdate( + updates, + names, + values, + '#runnerLabels', + ':runnerLabels', + RUNNER_LABELS_ATTRIBUTE, + activation.runnerLabels, + ); + addOptionalStringUpdate( + updates, + names, + values, + '#githubRunnerId', + ':githubRunnerId', + GITHUB_RUNNER_ID_ATTRIBUTE, + activation.githubRunnerId, + ); + if (activation.metadata !== undefined) { + names['#metadata'] = METADATA_ATTRIBUTE; + values[':metadata'] = { + L: activation.metadata.map(({ key, value }) => ({ M: { key: { S: key }, value: { S: value } } })), + }; + updates.push('#metadata = :metadata'); + } +} + +function addOptionalStringUpdate( + updates: string[], + names: Record, + values: Record, + namePlaceholder: string, + valuePlaceholder: string, + attributeName: string, + value: string | undefined, +): void { + if (value !== undefined) { + names[namePlaceholder] = attributeName; + values[valuePlaceholder] = { S: value }; + updates.push(`${namePlaceholder} = ${valuePlaceholder}`); + } +} + +function addOptionalStringListUpdate( + updates: string[], + names: Record, + values: Record, + namePlaceholder: string, + valuePlaceholder: string, + attributeName: string, + value: string[] | undefined, +): void { + if (value !== undefined) { + names[namePlaceholder] = attributeName; + values[valuePlaceholder] = { L: value.map((entry) => ({ S: entry })) }; + updates.push(`${namePlaceholder} = ${valuePlaceholder}`); + } +} + +function setMetadata(item: Record, metadata: RunnerConfigMetadata[] | undefined): void { + if (metadata && metadata.length > 0) { + item[METADATA_ATTRIBUTE] = { + L: metadata.map(({ key, value }) => ({ M: { key: { S: key }, value: { S: value } } })), + }; + } +} + +function expiresAt(now: Date, ttlSeconds: number): string { + return (Math.floor(now.getTime() / 1000) + ttlSeconds).toString(); +} + +function invalidItem(itemId: string, attribute: string): Error { + return new Error(`Runner state item '${itemId}' has an invalid '${attribute}' attribute`); +} diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts index 46117ee622..57c3240c39 100644 --- a/lambdas/libs/storage-providers/core/index.ts +++ b/lambdas/libs/storage-providers/core/index.ts @@ -20,6 +20,7 @@ export interface RunnerConfigMetadata { export interface RunnerConfigRecord { runnerId: string; value: string; + accessScope?: string; } export interface RunnerConfigStore { @@ -41,3 +42,53 @@ export interface RunnerGroupCacheStore { export interface RunnerMatcherConfigStore { get(): Promise; } + +export type RunnerType = 'Org' | 'Repo'; +export type RunnerLifecycleState = 'provisioning' | 'active' | 'orphan' | 'terminating'; + +export interface RunnerStateRecord { + runnerId: string; + computeProvider: string; + computeResourceId: string; + runnerName?: string; + runnerLabels?: string[]; + githubRunnerId?: string; + runnerOwner: string; + runnerType: RunnerType; + state: RunnerLifecycleState; + createdAt: string; + updatedAt: string; + metadata?: RunnerConfigMetadata[]; +} + +export type CreateRunnerStateRecord = Omit; + +export interface RunnerStateActivation { + runnerName?: string; + runnerLabels?: string[]; + githubRunnerId?: string; + metadata?: RunnerConfigMetadata[]; +} + +export type RunnerGitHubIdentity = RunnerStateActivation & { githubRunnerId: string }; + +export interface RunnerStateFilter { + computeProvider?: string; +} + +/** + * Provider-neutral index of compute resources that implement GitHub runners. + * Runner bootstrap configuration is deliberately stored by RunnerConfigStore + * in a separate item because it contains a short-lived secret payload. + */ +export interface RunnerStateStore { + create(record: CreateRunnerStateRecord): Promise; + recordGitHubIdentity(runnerId: string, identity: RunnerGitHubIdentity): Promise; + activate(runnerId: string, activation?: RunnerStateActivation): Promise; + list(filter?: RunnerStateFilter): Promise; + markOrphan(runnerId: string): Promise; + unmarkOrphan(runnerId: string): Promise; + beginTermination(runnerId: string): Promise; + cancelTermination(runnerId: string, restoreState: 'provisioning' | 'active' | 'orphan'): Promise; + delete(runnerId: string): Promise; +} diff --git a/lambdas/libs/storage-providers/github-app-credentials.test.ts b/lambdas/libs/storage-providers/github-app-credentials.test.ts index f043f789ad..8b28f4ae8e 100644 --- a/lambdas/libs/storage-providers/github-app-credentials.test.ts +++ b/lambdas/libs/storage-providers/github-app-credentials.test.ts @@ -1,17 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAwsDynamoDbGitHubAppCredentialsStore } from './aws/dynamodb/github-app-credentials-store'; import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; import type { GitHubAppCredentialsStore } from './core'; import { getGitHubAppCredentialsStore, resetGitHubAppCredentialsStore } from './github-app-credentials'; +vi.mock('./aws/dynamodb/github-app-credentials-store', () => ({ + createAwsDynamoDbGitHubAppCredentialsStore: vi.fn(), +})); vi.mock('./aws/ssm/github-app-credentials-store', () => ({ createAwsSsmGitHubAppCredentialsStore: vi.fn(), })); -const createAwsSsmGitHubAppCredentialsStoreMock = vi.mocked(createAwsSsmGitHubAppCredentialsStore); +const createAwsDynamoDbStoreMock = vi.mocked(createAwsDynamoDbGitHubAppCredentialsStore); +const createAwsSsmStoreMock = vi.mocked(createAwsSsmGitHubAppCredentialsStore); const cleanEnv = process.env; -describe('GitHub App credentials store', () => { +describe('GitHub App credentials store selection', () => { beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; @@ -19,39 +24,55 @@ describe('GitHub App credentials store', () => { resetGitHubAppCredentialsStore(); }); - it.each([undefined, 'aws_ssm', 'aws_dynamodb', 'not-registered'])( - 'remains on aws_ssm for runner config provider input %j', - (provider) => { - setProvider(provider); - const store = stubStore(); + it.each([undefined, '', ' ', 'aws_ssm', ' AWS_SSM '])('uses aws_ssm for selector input %j', (provider) => { + setProvider(provider); + const store = stubSsmStore(); + + expect(getGitHubAppCredentialsStore()).toBe(store); + expect(createAwsSsmStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbStoreMock).not.toHaveBeenCalled(); + }); + + it.each(['aws_dynamodb', ' AWS_DYNAMODB '])('uses aws_dynamodb for selector input %j', (provider) => { + setProvider(provider); + const store = stubDynamoDbStore(); + + expect(getGitHubAppCredentialsStore()).toBe(store); + expect(createAwsDynamoDbStoreMock).toHaveBeenCalledOnce(); + expect(createAwsSsmStoreMock).not.toHaveBeenCalled(); + }); + + it('rejects an unsupported provider before creating a store', () => { + setProvider('not-registered'); - expect(getGitHubAppCredentialsStore()).toBe(store); - expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); - }, - ); + expect(() => getGitHubAppCredentialsStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsSsmStoreMock).not.toHaveBeenCalled(); + expect(createAwsDynamoDbStoreMock).not.toHaveBeenCalled(); + }); - it('creates the store lazily and caches it', () => { - const store = stubStore(); + it('creates the selected store lazily and caches it', () => { + const store = stubSsmStore(); - expect(createAwsSsmGitHubAppCredentialsStoreMock).not.toHaveBeenCalled(); + expect(createAwsSsmStoreMock).not.toHaveBeenCalled(); const first = getGitHubAppCredentialsStore(); + setProvider('not-registered'); const second = getGitHubAppCredentialsStore(); expect(first).toBe(store); expect(second).toBe(store); - expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledOnce(); + expect(createAwsSsmStoreMock).toHaveBeenCalledOnce(); }); it('selects again after the test reset', () => { - const firstStore = stubStore(); + const firstStore = stubSsmStore(); expect(getGitHubAppCredentialsStore()).toBe(firstStore); const secondStore = { get: vi.fn() } satisfies GitHubAppCredentialsStore; - createAwsSsmGitHubAppCredentialsStoreMock.mockReturnValue(secondStore); + createAwsSsmStoreMock.mockReturnValue(secondStore); resetGitHubAppCredentialsStore(); expect(getGitHubAppCredentialsStore()).toBe(secondStore); - expect(createAwsSsmGitHubAppCredentialsStoreMock).toHaveBeenCalledTimes(2); + expect(createAwsSsmStoreMock).toHaveBeenCalledTimes(2); }); }); @@ -63,8 +84,14 @@ function setProvider(provider: string | undefined): void { } } -function stubStore(): GitHubAppCredentialsStore { +function stubSsmStore(): GitHubAppCredentialsStore { + const store = { get: vi.fn() } satisfies GitHubAppCredentialsStore; + createAwsSsmStoreMock.mockReturnValue(store); + return store; +} + +function stubDynamoDbStore(): GitHubAppCredentialsStore { const store = { get: vi.fn() } satisfies GitHubAppCredentialsStore; - createAwsSsmGitHubAppCredentialsStoreMock.mockReturnValue(store); + createAwsDynamoDbStoreMock.mockReturnValue(store); return store; } diff --git a/lambdas/libs/storage-providers/github-app-credentials.ts b/lambdas/libs/storage-providers/github-app-credentials.ts index c6189bb604..11322d826d 100644 --- a/lambdas/libs/storage-providers/github-app-credentials.ts +++ b/lambdas/libs/storage-providers/github-app-credentials.ts @@ -1,11 +1,21 @@ +import { createAwsDynamoDbGitHubAppCredentialsStore } from './aws/dynamodb/github-app-credentials-store'; import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; import type { GitHubAppCredentialsStore } from './core'; +import type {} from './environment'; +import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; + +type GitHubAppCredentialsStoreFactory = () => GitHubAppCredentialsStore; + +const providerFactories = { + aws_ssm: createAwsSsmGitHubAppCredentialsStore, + aws_dynamodb: createAwsDynamoDbGitHubAppCredentialsStore, +} as const satisfies Record; let githubAppCredentialsStore: GitHubAppCredentialsStore | undefined; export function getGitHubAppCredentialsStore(): GitHubAppCredentialsStore { - // GitHub App credentials remain in SSM independently of runner config storage selection. - githubAppCredentialsStore ??= createAwsSsmGitHubAppCredentialsStore(); + githubAppCredentialsStore ??= + providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); return githubAppCredentialsStore; } diff --git a/lambdas/libs/storage-providers/github-webhook-secret.test.ts b/lambdas/libs/storage-providers/github-webhook-secret.test.ts index b4b742733a..9b66f840be 100644 --- a/lambdas/libs/storage-providers/github-webhook-secret.test.ts +++ b/lambdas/libs/storage-providers/github-webhook-secret.test.ts @@ -1,17 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAwsDynamoDbGitHubWebhookSecretStore } from './aws/dynamodb/github-webhook-secret-store'; import { createAwsSsmGitHubWebhookSecretStore } from './aws/ssm/github-webhook-secret-store'; import type { GitHubWebhookSecretStore } from './core'; import { getGitHubWebhookSecretStore, resetGitHubWebhookSecretStore } from './github-webhook-secret'; +vi.mock('./aws/dynamodb/github-webhook-secret-store', () => ({ + createAwsDynamoDbGitHubWebhookSecretStore: vi.fn(), +})); vi.mock('./aws/ssm/github-webhook-secret-store', () => ({ createAwsSsmGitHubWebhookSecretStore: vi.fn(), })); -const createAwsSsmGitHubWebhookSecretStoreMock = vi.mocked(createAwsSsmGitHubWebhookSecretStore); +const createAwsDynamoDbStoreMock = vi.mocked(createAwsDynamoDbGitHubWebhookSecretStore); +const createAwsSsmStoreMock = vi.mocked(createAwsSsmGitHubWebhookSecretStore); const cleanEnv = process.env; -describe('GitHub webhook secret store', () => { +describe('GitHub webhook secret store selection', () => { beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; @@ -19,39 +24,55 @@ describe('GitHub webhook secret store', () => { resetGitHubWebhookSecretStore(); }); - it.each([undefined, 'aws_ssm', 'aws_dynamodb', 'not-registered'])( - 'remains on aws_ssm for runner config provider input %j', - (provider) => { - setProvider(provider); - const store = stubStore(); + it.each([undefined, '', ' ', 'aws_ssm', ' AWS_SSM '])('uses aws_ssm for selector input %j', (provider) => { + setProvider(provider); + const store = stubSsmStore(); + + expect(getGitHubWebhookSecretStore()).toBe(store); + expect(createAwsSsmStoreMock).toHaveBeenCalledOnce(); + expect(createAwsDynamoDbStoreMock).not.toHaveBeenCalled(); + }); + + it.each(['aws_dynamodb', ' AWS_DYNAMODB '])('uses aws_dynamodb for selector input %j', (provider) => { + setProvider(provider); + const store = stubDynamoDbStore(); + + expect(getGitHubWebhookSecretStore()).toBe(store); + expect(createAwsDynamoDbStoreMock).toHaveBeenCalledOnce(); + expect(createAwsSsmStoreMock).not.toHaveBeenCalled(); + }); + + it('rejects an unsupported provider before creating a store', () => { + setProvider('not-registered'); - expect(getGitHubWebhookSecretStore()).toBe(store); - expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); - }, - ); + expect(() => getGitHubWebhookSecretStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsSsmStoreMock).not.toHaveBeenCalled(); + expect(createAwsDynamoDbStoreMock).not.toHaveBeenCalled(); + }); - it('creates the store lazily and caches it', () => { - const store = stubStore(); + it('creates the selected store lazily and caches it', () => { + const store = stubSsmStore(); - expect(createAwsSsmGitHubWebhookSecretStoreMock).not.toHaveBeenCalled(); + expect(createAwsSsmStoreMock).not.toHaveBeenCalled(); const first = getGitHubWebhookSecretStore(); + setProvider('not-registered'); const second = getGitHubWebhookSecretStore(); expect(first).toBe(store); expect(second).toBe(store); - expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledOnce(); + expect(createAwsSsmStoreMock).toHaveBeenCalledOnce(); }); it('selects again after the test reset', () => { - const firstStore = stubStore(); + const firstStore = stubSsmStore(); expect(getGitHubWebhookSecretStore()).toBe(firstStore); const secondStore = { get: vi.fn() } satisfies GitHubWebhookSecretStore; - createAwsSsmGitHubWebhookSecretStoreMock.mockReturnValue(secondStore); + createAwsSsmStoreMock.mockReturnValue(secondStore); resetGitHubWebhookSecretStore(); expect(getGitHubWebhookSecretStore()).toBe(secondStore); - expect(createAwsSsmGitHubWebhookSecretStoreMock).toHaveBeenCalledTimes(2); + expect(createAwsSsmStoreMock).toHaveBeenCalledTimes(2); }); }); @@ -63,8 +84,14 @@ function setProvider(provider: string | undefined): void { } } -function stubStore(): GitHubWebhookSecretStore { +function stubSsmStore(): GitHubWebhookSecretStore { + const store = { get: vi.fn() } satisfies GitHubWebhookSecretStore; + createAwsSsmStoreMock.mockReturnValue(store); + return store; +} + +function stubDynamoDbStore(): GitHubWebhookSecretStore { const store = { get: vi.fn() } satisfies GitHubWebhookSecretStore; - createAwsSsmGitHubWebhookSecretStoreMock.mockReturnValue(store); + createAwsDynamoDbStoreMock.mockReturnValue(store); return store; } diff --git a/lambdas/libs/storage-providers/github-webhook-secret.ts b/lambdas/libs/storage-providers/github-webhook-secret.ts index f6abfc5349..8fd0a1758c 100644 --- a/lambdas/libs/storage-providers/github-webhook-secret.ts +++ b/lambdas/libs/storage-providers/github-webhook-secret.ts @@ -1,11 +1,21 @@ +import { createAwsDynamoDbGitHubWebhookSecretStore } from './aws/dynamodb/github-webhook-secret-store'; import { createAwsSsmGitHubWebhookSecretStore } from './aws/ssm/github-webhook-secret-store'; import type { GitHubWebhookSecretStore } from './core'; +import type {} from './environment'; +import { resolveRunnerConfigStorageProvider, type RunnerConfigStorageProvider } from './provider'; + +type GitHubWebhookSecretStoreFactory = () => GitHubWebhookSecretStore; + +const providerFactories = { + aws_ssm: createAwsSsmGitHubWebhookSecretStore, + aws_dynamodb: createAwsDynamoDbGitHubWebhookSecretStore, +} as const satisfies Record; let githubWebhookSecretStore: GitHubWebhookSecretStore | undefined; export function getGitHubWebhookSecretStore(): GitHubWebhookSecretStore { - // The webhook secret remains in SSM independently of runner config storage selection. - githubWebhookSecretStore ??= createAwsSsmGitHubWebhookSecretStore(); + githubWebhookSecretStore ??= + providerFactories[resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER)](); return githubWebhookSecretStore; } diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index 49e9d11c18..5dde65a02e 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -1,4 +1,5 @@ export type { + CreateRunnerStateRecord, GitHubAppCredential, GitHubAppCredentialsStore, GitHubWebhookSecretStore, @@ -7,10 +8,18 @@ export type { RunnerConfigStore, RunnerGroupCacheRecord, RunnerGroupCacheStore, + RunnerGitHubIdentity, RunnerMatcherConfigStore, + RunnerLifecycleState, + RunnerStateActivation, + RunnerStateFilter, + RunnerStateRecord, + RunnerStateStore, + RunnerType, } from './core'; export { getGitHubAppCredentialsStore, resetGitHubAppCredentialsStore } from './github-app-credentials'; export { getGitHubWebhookSecretStore, resetGitHubWebhookSecretStore } from './github-webhook-secret'; export { getRunnerConfigStore, resetRunnerConfigStore } from './runner-config'; export { getRunnerGroupCacheStore, resetRunnerGroupCacheStore } from './runner-group-cache'; export { getRunnerMatcherConfigStore, resetRunnerMatcherConfigStore } from './runner-matcher-config'; +export { getRunnerStateStore, resetRunnerStateStore } from './runner-state'; diff --git a/lambdas/libs/storage-providers/runner-state.test.ts b/lambdas/libs/storage-providers/runner-state.test.ts new file mode 100644 index 0000000000..0d997614e7 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-state.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAwsDynamoDbRunnerStateStore } from './aws/dynamodb/runner-state-store'; +import type { RunnerStateStore } from './core'; +import { getRunnerStateStore, resetRunnerStateStore } from './runner-state'; + +vi.mock('./aws/dynamodb/runner-state-store', () => ({ + createAwsDynamoDbRunnerStateStore: vi.fn(), +})); + +const createAwsDynamoDbRunnerStateStoreMock = vi.mocked(createAwsDynamoDbRunnerStateStore); +const cleanEnv = process.env; + +describe('runner state store selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + resetRunnerStateStore(); + }); + + it.each([undefined, '', ' ', 'aws_ssm', ' AWS_SSM '])( + 'returns no inventory capability for aws_ssm selector input %j', + (provider) => { + setProvider(provider); + + expect(getRunnerStateStore()).toBeUndefined(); + expect(createAwsDynamoDbRunnerStateStoreMock).not.toHaveBeenCalled(); + }, + ); + + it.each(['aws_dynamodb', ' AWS_DYNAMODB '])('creates the DynamoDB inventory for selector input %j', (provider) => { + setProvider(provider); + const store = stubStore(); + + expect(getRunnerStateStore()).toBe(store); + expect(createAwsDynamoDbRunnerStateStoreMock).toHaveBeenCalledOnce(); + }); + + it('rejects an unsupported provider before creating a store', () => { + setProvider('not-registered'); + + expect(() => getRunnerStateStore()).toThrow("Unsupported runner config storage provider 'not-registered'"); + expect(createAwsDynamoDbRunnerStateStoreMock).not.toHaveBeenCalled(); + }); + + it('caches the DynamoDB store until reset', () => { + setProvider('aws_dynamodb'); + const firstStore = stubStore(); + + expect(getRunnerStateStore()).toBe(firstStore); + expect(getRunnerStateStore()).toBe(firstStore); + expect(createAwsDynamoDbRunnerStateStoreMock).toHaveBeenCalledOnce(); + + const secondStore = createStubStore(); + createAwsDynamoDbRunnerStateStoreMock.mockReturnValue(secondStore); + resetRunnerStateStore(); + expect(getRunnerStateStore()).toBe(secondStore); + expect(createAwsDynamoDbRunnerStateStoreMock).toHaveBeenCalledTimes(2); + }); +}); + +function setProvider(provider: string | undefined): void { + if (provider === undefined) { + delete process.env.RUNNER_CONFIG_STORAGE_PROVIDER; + } else { + process.env.RUNNER_CONFIG_STORAGE_PROVIDER = provider; + } +} + +function stubStore(): RunnerStateStore { + const store = createStubStore(); + createAwsDynamoDbRunnerStateStoreMock.mockReturnValue(store); + return store; +} + +function createStubStore(): RunnerStateStore { + return { + create: vi.fn(), + recordGitHubIdentity: vi.fn(), + activate: vi.fn(), + list: vi.fn(), + markOrphan: vi.fn(), + unmarkOrphan: vi.fn(), + beginTermination: vi.fn(), + cancelTermination: vi.fn(), + delete: vi.fn(), + }; +} diff --git a/lambdas/libs/storage-providers/runner-state.ts b/lambdas/libs/storage-providers/runner-state.ts new file mode 100644 index 0000000000..e09a09d2b6 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-state.ts @@ -0,0 +1,25 @@ +import { createAwsDynamoDbRunnerStateStore } from './aws/dynamodb/runner-state-store'; +import type { RunnerStateStore } from './core'; +import type {} from './environment'; +import { resolveRunnerConfigStorageProvider } from './provider'; + +let runnerStateStore: RunnerStateStore | undefined; + +export function getRunnerStateStore(): RunnerStateStore | undefined { + if (runnerStateStore) { + return runnerStateStore; + } + + const provider = resolveRunnerConfigStorageProvider(process.env.RUNNER_CONFIG_STORAGE_PROVIDER); + if (provider !== 'aws_dynamodb') { + return undefined; + } + + runnerStateStore = createAwsDynamoDbRunnerStateStore(); + return runnerStateStore; +} + +// Test-only reset for cases that need to exercise first-use environment selection. +export function resetRunnerStateStore(): void { + runnerStateStore = undefined; +} diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts index 0b0b3356e8..21626b5cc9 100644 --- a/lambdas/libs/storage-providers/vitest.config.ts +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -15,6 +15,7 @@ export default mergeConfig(defaultConfig, { 'runner-config.ts', 'runner-group-cache.ts', 'runner-matcher-config.ts', + 'runner-state.ts', 'core/**/*.ts', 'aws/**/*.ts', ], From ab5613c2afc4e0ba13af7128d994a76e8abc0c28 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 15:01:39 +0200 Subject: [PATCH 47/49] feat(terraform): wire shared DynamoDB storage --- .github/workflows/terraform.yml | 2 + modules/compute-providers/aws/ec2/README.md | 13 +- .../aws/ec2/control-plane.tf | 6 +- .../aws/ec2/policies-runner.tf | 16 +- .../aws/ec2/runner-config.tf | 12 + .../aws/ec2/runner-instances.tf | 7 +- .../aws/ec2/templates/start-runner-osx.sh | 63 +++++ .../aws/ec2/templates/start-runner.ps1 | 73 ++++++ .../aws/ec2/templates/start-runner.sh | 64 +++++ .../aws/ec2/tests/provider.tftest.hcl | 84 +++++- .../compute-providers/aws/ec2/variables.tf | 37 +++ modules/multi-runner/README.md | 20 +- .../config.experimental.translation.tf | 7 + modules/multi-runner/runners.experimental.tf | 8 + modules/multi-runner/storage-provider.tf | 160 ++++++++++++ .../tests/provider-routing-v1.tftest.hcl | 5 + .../tests/provider-routing-v2.tftest.hcl | 166 +++++++++++- .../multi-runner/variables.experimental.tf | 88 +++++++ modules/multi-runner/webhook.tf | 7 +- .../orchestration-providers/webhook/README.md | 13 +- .../webhook/job-retry.tf | 5 + .../webhook/job-retry/README.md | 11 +- .../webhook/job-retry/iam-policies.tf | 32 ++- .../webhook/job-retry/job-retry.tf | 21 +- .../webhook/job-retry/variables.tf | 14 + .../orchestration-providers/webhook/main.tf | 13 +- .../orchestration-providers/webhook/pool.tf | 4 + .../webhook/pool/README.md | 11 +- .../webhook/pool/iam-policies.tf | 88 ++++--- .../webhook/pool/pool.tf | 55 ++-- .../webhook/pool/variables.tf | 14 + .../webhook/scale-runners.tf | 6 + .../webhook/scale-runners/README.md | 11 +- .../scale-runners/scale-down-iam-policies.tf | 35 +-- .../webhook/scale-runners/scale-down.tf | 35 +-- .../scale-runners/scale-up-iam-policies.tf | 71 ++--- .../webhook/scale-runners/scale-up.tf | 51 ++-- .../webhook/scale-runners/variables.tf | 26 ++ .../webhook/variables.tf | 42 +++ modules/runner-config/README.md | 15 +- .../runner-config/compute-provider.aws.ec2.tf | 8 +- .../runner-config/orchestration-provider.tf | 7 + .../runner-config/runner-ssm-parameters.tf | 24 ++ modules/runner-config/ssm-housekeeper.tf | 6 + modules/runner-config/tests/pool.tftest.hcl | 4 +- modules/runner-config/tests/tags.tftest.hcl | 2 +- .../variables.storage-provider.tf | 57 ++++ .../storage-providers/aws/dynamodb/README.md | 57 ++++ .../aws/dynamodb/capabilities.tf | 243 +++++++++++++++++ .../aws/dynamodb/config-version.tf | 6 + .../storage-providers/aws/dynamodb/items.tf | 56 ++++ .../storage-providers/aws/dynamodb/outputs.tf | 71 +++++ .../storage-providers/aws/dynamodb/tables.tf | 62 +++++ .../aws/dynamodb/tests/provider.tftest.hcl | 245 ++++++++++++++++++ .../aws/dynamodb/variables.tf | 99 +++++++ .../aws/dynamodb/versions.tf | 10 + modules/webhook/README.md | 15 +- modules/webhook/direct/README.md | 12 +- modules/webhook/direct/variables.tf | 9 + modules/webhook/direct/webhook.tf | 21 +- modules/webhook/eventbridge/README.md | 12 +- modules/webhook/eventbridge/dispatcher.tf | 19 +- modules/webhook/eventbridge/variables.tf | 21 ++ modules/webhook/eventbridge/webhook.tf | 19 +- modules/webhook/variables.tf | 43 +++ modules/webhook/webhook.tf | 12 +- 66 files changed, 2272 insertions(+), 279 deletions(-) create mode 100644 modules/multi-runner/storage-provider.tf create mode 100644 modules/runner-config/variables.storage-provider.tf create mode 100644 modules/storage-providers/aws/dynamodb/README.md create mode 100644 modules/storage-providers/aws/dynamodb/capabilities.tf create mode 100644 modules/storage-providers/aws/dynamodb/config-version.tf create mode 100644 modules/storage-providers/aws/dynamodb/items.tf create mode 100644 modules/storage-providers/aws/dynamodb/outputs.tf create mode 100644 modules/storage-providers/aws/dynamodb/tables.tf create mode 100644 modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl create mode 100644 modules/storage-providers/aws/dynamodb/variables.tf create mode 100644 modules/storage-providers/aws/dynamodb/versions.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 242a0c4412..baecdf60ae 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -88,6 +88,7 @@ jobs: "compute-providers/aws/ec2", "compute-providers/aws/ec2/trust-policy", "runner-binaries-syncer", + "storage-providers/aws/dynamodb", "orchestration-providers/webhook", "orchestration-providers/webhook/job-retry", "orchestration-providers/webhook/pool", @@ -229,6 +230,7 @@ jobs: - modules/orchestration-providers/webhook/scale-runners - modules/runner-config - modules/runner-config/ssm-housekeeper + - modules/storage-providers/aws/dynamodb - modules/compute-providers/aws/ec2 - modules/compute-providers/aws/ec2/trust-policy defaults: diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md index 435623f6be..b568b9419b 100644 --- a/modules/compute-providers/aws/ec2/README.md +++ b/modules/compute-providers/aws/ec2/README.md @@ -10,15 +10,15 @@ EC2 is the only active compute provider. The parent runner configuration selects ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | | [terraform](#provider\_terraform) | n/a | ## Modules @@ -28,7 +28,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 | @@ -58,7 +58,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 by compute-provider resources and policy documents. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 | @@ -67,12 +67,13 @@ No modules. | [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no | | [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture.
- `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 provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-config manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
- `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | | [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `paths.root`: Root Parameter Store path for the runner configuration.
- `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 | +| [storage\_provider](#input\_storage\_provider) | Runner-side storage locator and opaque IAM policy supplied by runner-config. The default preserves the existing SSM bootstrap path. |
object({
type = string
runner = object({
config_table_name = optional(string, null)
runner_state_table_name = optional(string, null)
scope = optional(string, null)
iam_policy_json = optional(string, null)
})
})
|
{
"runner": {
"config_table_name": null,
"iam_policy_json": null,
"runner_state_table_name": null,
"scope": null
},
"type": "aws_ssm"
}
| no | | [tags](#input\_tags) | Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | | [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | diff --git a/modules/compute-providers/aws/ec2/control-plane.tf b/modules/compute-providers/aws/ec2/control-plane.tf index d476f91cb4..4b917508c8 100644 --- a/modules/compute-providers/aws/ec2/control-plane.tf +++ b/modules/compute-providers/aws/ec2/control-plane.tf @@ -191,7 +191,7 @@ data "aws_iam_policy_document" "service_linked_role" { } locals { - scale_up_environment_variables = { + scale_up_environment_variables = merge({ AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name INSTANCE_ALLOCATION_STRATEGY = var.config.instance_allocation_strategy INSTANCE_MAX_SPOT_PRICE = var.config.instance_max_spot_price @@ -203,7 +203,9 @@ locals { 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 - } + }, var.storage_provider.type == "aws_dynamodb" ? { + EC2_INSTANCE_ARN_PREFIX = local.ec2_instance_arn_prefix + } : {}) scale_down_environment_variables = {} diff --git a/modules/compute-providers/aws/ec2/policies-runner.tf b/modules/compute-providers/aws/ec2/policies-runner.tf index c16077debc..c4f387846b 100644 --- a/modules/compute-providers/aws/ec2/policies-runner.tf +++ b/modules/compute-providers/aws/ec2/policies-runner.tf @@ -4,6 +4,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" + ec2_instance_arn_prefix = "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/" 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" } @@ -167,10 +168,6 @@ data "aws_iam_policy_document" "cloudwatch" { locals { runner_inline_policies = merge( { - ssm_parameters = { - name = "runner-ssm-parameters" - policy_json = data.aws_iam_policy_document.ssm_parameters.json - } describe_tags = { name = "runner-describe-tags" policy_json = data.aws_iam_policy_document.describe_tags.json @@ -184,6 +181,17 @@ locals { policy_json = data.aws_iam_policy_document.terminate_self.json } }, + var.storage_provider.type == "aws_ssm" ? { + ssm_parameters = { + name = "runner-ssm-parameters" + policy_json = data.aws_iam_policy_document.ssm_parameters.json + } + } : { + runner_config_storage = { + name = "runner-config-storage" + policy_json = var.storage_provider.runner.iam_policy_json + } + }, var.config.ssm_enabled ? { session_manager = { name = "runner-ssm-session" diff --git a/modules/compute-providers/aws/ec2/runner-config.tf b/modules/compute-providers/aws/ec2/runner-config.tf index f1d859581c..f562047a7f 100644 --- a/modules/compute-providers/aws/ec2/runner-config.tf +++ b/modules/compute-providers/aws/ec2/runner-config.tf @@ -1,4 +1,5 @@ resource "aws_ssm_parameter" "runner_config_run_as" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/run_as" type = "String" value = var.runner.run_as_root ? "root" : var.runner.run_as @@ -6,8 +7,19 @@ resource "aws_ssm_parameter" "runner_config_run_as" { } resource "aws_ssm_parameter" "runner_enable_cloudwatch" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_cloudwatch" type = "String" value = var.config.cloudwatch_agent.enabled tags = local.ssm_parameter_tags } + +moved { + from = aws_ssm_parameter.runner_config_run_as + to = aws_ssm_parameter.runner_config_run_as[0] +} + +moved { + from = aws_ssm_parameter.runner_enable_cloudwatch + to = aws_ssm_parameter.runner_enable_cloudwatch[0] +} diff --git a/modules/compute-providers/aws/ec2/runner-instances.tf b/modules/compute-providers/aws/ec2/runner-instances.tf index e965d8f66c..2837034a87 100644 --- a/modules/compute-providers/aws/ec2/runner-instances.tf +++ b/modules/compute-providers/aws/ec2/runner-instances.tf @@ -90,7 +90,12 @@ locals { 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" + metadata_tags = var.config.metadata_options != null ? var.config.metadata_options.instance_metadata_tags : "enabled" + storage_provider_type = var.storage_provider.type + dynamodb_config_table_name_base64 = var.storage_provider.type == "aws_dynamodb" ? base64encode(var.storage_provider.runner.config_table_name) : "" + dynamodb_scope_base64 = var.storage_provider.type == "aws_dynamodb" ? base64encode(var.storage_provider.runner.scope) : "" + ec2_instance_arn_prefix_base64 = var.storage_provider.type == "aws_dynamodb" ? base64encode(local.ec2_instance_arn_prefix) : "" + enable_cloudwatch_agent = var.config.cloudwatch_agent.enabled }) ghes_url = var.github.enterprise_server.url ghes_ssl_verify = var.github.enterprise_server.ssl_verify diff --git a/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh b/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh index a6da66116d..c0a274bb45 100644 --- a/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh +++ b/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh @@ -88,6 +88,37 @@ 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)" +%{ if storage_provider_type == "aws_dynamodb" } +dynamodb_config_table_name=$(printf '%s' "${dynamodb_config_table_name_base64}" | openssl base64 -d -A) +dynamodb_scope=$(printf '%s' "${dynamodb_scope_base64}" | openssl base64 -d -A) +ec2_instance_arn_prefix=$(printf '%s' "${ec2_instance_arn_prefix_base64}" | openssl base64 -d -A) + +echo "Retrieving runner bootstrap configuration from DynamoDB" +runner_config_key=$(jq -cn --arg scope "$dynamodb_scope" '{scope:{S:$scope},id:{S:"runner-config"}}') +runner_config_record=$(aws dynamodb get-item \ + --table-name "$dynamodb_config_table_name" \ + --key "$runner_config_key" \ + --consistent-read \ + --projection-expression "#value" \ + --expression-attribute-names '{"#value":"value"}' \ + --region "$region") +runner_config=$(printf '%s' "$runner_config_record" | jq -er '.Item.value.S | fromjson') +unset runner_config_record + +run_as=$(printf '%s' "$runner_config" | jq -r '.run_as') +agent_mode=$(printf '%s' "$runner_config" | jq -r '.agent_mode') +disable_default_labels=$(printf '%s' "$runner_config" | jq -r '.disable_default_labels') +enable_jit_config=$(printf '%s' "$runner_config" | jq -r '.enable_jit_config') +dynamodb_runner_state_table_name=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.table_name') +dynamodb_access_scope_type=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.access_scope') +dynamodb_config_id=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.id') +if [[ "$dynamodb_access_scope_type" != "compute-resource" ]]; then + echo "Unsupported runner configuration access scope" + exit 1 +fi +dynamodb_access_scope="$${ec2_instance_arn_prefix}$instance_id" +unset runner_config +%{ else } parameters=$(aws ssm get-parameters-by-path \ --path "$ssm_config_path" \ --region "$region" \ @@ -108,7 +139,38 @@ echo "Retrieved /$ssm_config_path/enable_jit_config parameter - ($enable_jit_con token_path=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/token_path") | .Value') echo "Retrieved /$ssm_config_path/token_path parameter - ($token_path)" +%{ endif } + +%{ if storage_provider_type == "aws_dynamodb" } +echo "Retrieving one-time runner configuration from DynamoDB" +runner_state_key=$(jq -cn --arg scope "$dynamodb_access_scope" --arg id "$dynamodb_config_id" '{scope:{S:$scope},id:{S:$id}}') +config="" +retrycount=0 +while [[ -z "$config" ]]; do + now_epoch=$(date +%s) + expression_values=$(jq -cn --arg now "$now_epoch" '{":now":{N:$now}}') + if config_record=$(aws dynamodb delete-item \ + --table-name "$dynamodb_runner_state_table_name" \ + --key "$runner_state_key" \ + --condition-expression "attribute_exists(#expires_at) AND #expires_at > :now" \ + --expression-attribute-names '{"#expires_at":"expires_at"}' \ + --expression-attribute-values "$expression_values" \ + --return-values ALL_OLD \ + --region "$region" 2>/dev/null); then + config=$(printf '%s' "$config_record" | jq -er '.Attributes.value.S') + unset config_record + break + fi + retrycount=$((retrycount + 1)) + if [[ $retrycount -gt 40 ]]; then + echo "Runner configuration was unavailable or expired" + exit 1 + fi + echo "Waiting for runner configuration to become available in DynamoDB" + sleep 1 +done +%{ else } 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 @@ -119,6 +181,7 @@ done echo "Delete GH Runner token from AWS SSM" aws ssm delete-parameter --name "$token_path"/"$instance_id" --region "$region" +%{ endif } if [ -z "$run_as" ]; then echo "No user specified, using default ec2-user account" diff --git a/modules/compute-providers/aws/ec2/templates/start-runner.ps1 b/modules/compute-providers/aws/ec2/templates/start-runner.ps1 index ae2eeff3c9..8d88b37e8b 100644 --- a/modules/compute-providers/aws/ec2/templates/start-runner.ps1 +++ b/modules/compute-providers/aws/ec2/templates/start-runner.ps1 @@ -77,6 +77,43 @@ 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)" +%{ if storage_provider_type == "aws_dynamodb" } +$DynamoDbConfigTableName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${dynamodb_config_table_name_base64}")) +$DynamoDbScope = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${dynamodb_scope_base64}")) +$Ec2InstanceArnPrefix = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${ec2_instance_arn_prefix_base64}")) + +Write-Host "Retrieving runner bootstrap configuration from DynamoDB" +$RunnerConfigKey = @{ + scope = @{ S = $DynamoDbScope } + id = @{ S = "runner-config" } +} | ConvertTo-Json -Compress +$RunnerConfigRecord = aws dynamodb get-item ` + --table-name $DynamoDbConfigTableName ` + --key $RunnerConfigKey ` + --consistent-read ` + --projection-expression "#value" ` + --expression-attribute-names '{"#value":"value"}' ` + --region $Region | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or -not $RunnerConfigRecord.Item.value.S) { + throw "Runner bootstrap configuration is unavailable" +} +$RunnerConfig = $RunnerConfigRecord.Item.value.S | ConvertFrom-Json +$RunnerConfigRecord = $null + +$run_as = $RunnerConfig.run_as +$agent_mode = $RunnerConfig.agent_mode +$disable_default_labels = $RunnerConfig.disable_default_labels.ToString().ToLowerInvariant() +$enable_jit_config = $RunnerConfig.enable_jit_config.ToString().ToLowerInvariant() +$enable_cloudwatch_agent = "${enable_cloudwatch_agent}" +$DynamoDbRunnerStateTableName = $RunnerConfig.runner_config_storage.table_name +$DynamoDbAccessScopeType = $RunnerConfig.runner_config_storage.access_scope +$DynamoDbConfigId = $RunnerConfig.runner_config_storage.id +if ($DynamoDbAccessScopeType -ne "compute-resource") { + throw "Unsupported runner configuration access scope" +} +$DynamoDbAccessScope = "$Ec2InstanceArnPrefix$InstanceId" +$RunnerConfig = $null +%{ else } $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" @@ -97,6 +134,7 @@ Write-Host "Retrieved $ssm_config_path/enable_jit_config parameter - ($enable_j $token_path=$parameters.where( {$_.Name -eq "$ssm_config_path/token_path"}).value Write-Host "Retrieved $ssm_config_path/token_path parameter - ($token_path)" +%{ endif } if ($enable_cloudwatch_agent -eq "true") @@ -107,6 +145,40 @@ if ($enable_cloudwatch_agent -eq "true") ## Configure the runner +%{ if storage_provider_type == "aws_dynamodb" } +Write-Host "Retrieving one-time runner configuration from DynamoDB" +$RunnerStateKey = @{ + scope = @{ S = $DynamoDbAccessScope } + id = @{ S = $DynamoDbConfigId } +} | ConvertTo-Json -Compress +$config = $null +$i = 0 +do { + $NowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString() + $ExpressionValues = @{ ":now" = @{ N = $NowEpoch } } | ConvertTo-Json -Compress + $ConfigRecordRaw = aws dynamodb delete-item ` + --table-name $DynamoDbRunnerStateTableName ` + --key $RunnerStateKey ` + --condition-expression "attribute_exists(#expires_at) AND #expires_at > :now" ` + --expression-attribute-names '{"#expires_at":"expires_at"}' ` + --expression-attribute-values $ExpressionValues ` + --return-values ALL_OLD ` + --region $Region 2>$null + if ($LASTEXITCODE -eq 0) { + $config = ($ConfigRecordRaw | ConvertFrom-Json).Attributes.value.S + $ConfigRecordRaw = $null + break + } + + Write-Host "Waiting for runner configuration to become available in DynamoDB ($i/40)" + Start-Sleep 1 + $i++ +} while (($null -eq $config) -and ($i -lt 40)) + +if ($null -eq $config) { + throw "Runner configuration was unavailable or expired" +} +%{ else } Write-Host "Get GH Runner config from AWS SSM" $config = $null $i = 0 @@ -119,6 +191,7 @@ do { Write-Host "Delete GH Runner token from AWS SSM" aws ssm delete-parameter --name "$token_path/$InstanceId" --region $Region +%{ endif } # Create or update user if (-not($run_as)) { diff --git a/modules/compute-providers/aws/ec2/templates/start-runner.sh b/modules/compute-providers/aws/ec2/templates/start-runner.sh index 7f2c0f82c5..b6533df1dc 100644 --- a/modules/compute-providers/aws/ec2/templates/start-runner.sh +++ b/modules/compute-providers/aws/ec2/templates/start-runner.sh @@ -159,6 +159,38 @@ 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)" +%{ if storage_provider_type == "aws_dynamodb" } +dynamodb_config_table_name=$(printf '%s' "${dynamodb_config_table_name_base64}" | openssl base64 -d -A) +dynamodb_scope=$(printf '%s' "${dynamodb_scope_base64}" | openssl base64 -d -A) +ec2_instance_arn_prefix=$(printf '%s' "${ec2_instance_arn_prefix_base64}" | openssl base64 -d -A) + +echo "Retrieving runner bootstrap configuration from DynamoDB" +runner_config_key=$(jq -cn --arg scope "$dynamodb_scope" '{scope:{S:$scope},id:{S:"runner-config"}}') +runner_config_record=$(aws dynamodb get-item \ + --table-name "$dynamodb_config_table_name" \ + --key "$runner_config_key" \ + --consistent-read \ + --projection-expression "#value" \ + --expression-attribute-names '{"#value":"value"}' \ + --region "$region") +runner_config=$(printf '%s' "$runner_config_record" | jq -er '.Item.value.S | fromjson') +unset runner_config_record + +run_as=$(printf '%s' "$runner_config" | jq -r '.run_as') +agent_mode=$(printf '%s' "$runner_config" | jq -r '.agent_mode') +disable_default_labels=$(printf '%s' "$runner_config" | jq -r '.disable_default_labels') +enable_jit_config=$(printf '%s' "$runner_config" | jq -r '.enable_jit_config') +enable_cloudwatch_agent="${enable_cloudwatch_agent}" +dynamodb_runner_state_table_name=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.table_name') +dynamodb_access_scope_type=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.access_scope') +dynamodb_config_id=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.id') +if [[ "$dynamodb_access_scope_type" != "compute-resource" ]]; then + echo "Unsupported runner configuration access scope" + exit 1 +fi +dynamodb_access_scope="$${ec2_instance_arn_prefix}$instance_id" +unset runner_config +%{ else } 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)" @@ -179,6 +211,7 @@ echo "Retrieved /$ssm_config_path/enable_jit_config parameter - ($enable_jit_con 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)" +%{ endif } if [[ "$xray_trace_id" != "" ]]; then # run xray service @@ -199,6 +232,36 @@ fi ## Configure the runner +%{ if storage_provider_type == "aws_dynamodb" } +echo "Retrieving one-time runner configuration from DynamoDB" +runner_state_key=$(jq -cn --arg scope "$dynamodb_access_scope" --arg id "$dynamodb_config_id" '{scope:{S:$scope},id:{S:$id}}') +config="" +retrycount=0 +while [[ -z "$config" ]]; do + now_epoch=$(date +%s) + expression_values=$(jq -cn --arg now "$now_epoch" '{":now":{N:$now}}') + if config_record=$(aws dynamodb delete-item \ + --table-name "$dynamodb_runner_state_table_name" \ + --key "$runner_state_key" \ + --condition-expression "attribute_exists(#expires_at) AND #expires_at > :now" \ + --expression-attribute-names '{"#expires_at":"expires_at"}' \ + --expression-attribute-values "$expression_values" \ + --return-values ALL_OLD \ + --region "$region" 2>/dev/null); then + config=$(printf '%s' "$config_record" | jq -er '.Attributes.value.S') + unset config_record + break + fi + + retrycount=$((retrycount + 1)) + if [[ $retrycount -gt 40 ]]; then + echo "Runner configuration was unavailable or expired" + exit 1 + fi + echo "Waiting for runner configuration to become available in DynamoDB" + sleep 1 +done +%{ else } 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 @@ -209,6 +272,7 @@ done echo "Delete GH Runner token from AWS SSM" aws ssm delete-parameter --name "$token_path"/"$instance_id" --region "$region" +%{ endif } if [ -z "$run_as" ]; then echo "No user specified, using default ec2-user account" diff --git a/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl index bc92537279..5de6ef2f1c 100644 --- a/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl @@ -367,11 +367,11 @@ run "separates_provider_runner_and_ssm_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") + aws_ssm_parameter.runner_config_run_as[0].tags["Name"] == "ssm-name" + && aws_ssm_parameter.runner_config_run_as[0].tags["Scope"] == "ssm" + && aws_ssm_parameter.runner_config_run_as[0].tags["SsmOnly"] == "ssm" + && !contains(keys(aws_ssm_parameter.runner_config_run_as[0].tags), "RunnerOnly") + && !contains(keys(aws_ssm_parameter.runner_config_run_as[0].tags), "ghr:environment") ) error_message = "EC2 SSM parameters must merge SSM component tags over provider tags." } @@ -449,3 +449,77 @@ run "requires_distribution_object_when_sync_is_enabled" { expect_failures = [terraform_data.validate_config] } + +run "dynamodb_bootstrap_is_opt_in_and_compute_scoped" { + command = plan + + variables { + 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 + } + cloudwatch_agent = { + enabled = false + } + managed_security_group_enabled = true + } + + runner = { + os = "windows" + architecture = "x64" + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/provider-test-runner" + name = "provider-test-runner" + } + } + } + + storage_provider = { + type = "aws_dynamodb" + runner = { + config_table_name = "provider-test-config" + runner_state_table_name = "provider-test-runner-state" + scope = "entry#unsafe-$(value)#bootstrap" + iam_policy_json = jsonencode({ Version = "2012-10-17", Statement = [] }) + } + } + } + + assert { + condition = ( + length(aws_ssm_parameter.runner_config_run_as) == 0 + && length(aws_ssm_parameter.runner_enable_cloudwatch) == 0 + && contains(keys(output.provider.policies.runner.inline_policies), "runner_config_storage") + && !contains(keys(output.provider.policies.runner.inline_policies), "ssm_parameters") + && output.provider.environment_variables.scale_up["EC2_INSTANCE_ARN_PREFIX"] == "arn:aws:ec2:eu-west-1:123456789012:instance/" + && output.provider.environment_variables.pool["EC2_INSTANCE_ARN_PREFIX"] == "arn:aws:ec2:eu-west-1:123456789012:instance/" + ) + error_message = "DynamoDB-selected EC2 runners must replace SSM bootstrap resources and expose the matching compute-resource ARN prefix." + } + + assert { + condition = ( + strcontains(base64decode(aws_launch_template.runner.user_data), base64encode("provider-test-config")) + && strcontains(base64decode(aws_launch_template.runner.user_data), base64encode("entry#unsafe-$(value)#bootstrap")) + && strcontains(base64decode(aws_launch_template.runner.user_data), base64encode("arn:aws:ec2:eu-west-1:123456789012:instance/")) + && !strcontains(base64decode(aws_launch_template.runner.user_data), "entry#unsafe-$(value)#bootstrap") + && strcontains(base64decode(aws_launch_template.runner.user_data), "aws dynamodb delete-item") + && strcontains(base64decode(aws_launch_template.runner.user_data), "attribute_exists(#expires_at) AND #expires_at > :now") + && strcontains(base64decode(aws_launch_template.runner.user_data), "--return-values ALL_OLD") + && !strcontains(base64decode(aws_launch_template.runner.user_data), "aws ssm get-parameters --names") + ) + error_message = "DynamoDB bootstrap must base64-embed user-controlled locators and atomically consume only an unexpired per-instance record." + } +} diff --git a/modules/compute-providers/aws/ec2/variables.tf b/modules/compute-providers/aws/ec2/variables.tf index f538d5026e..d9d91e5623 100644 --- a/modules/compute-providers/aws/ec2/variables.tf +++ b/modules/compute-providers/aws/ec2/variables.tf @@ -345,6 +345,43 @@ variable "ssm" { nullable = false } +variable "storage_provider" { + description = "Runner-side storage locator and opaque IAM policy supplied by runner-config. The default preserves the existing SSM bootstrap path." + type = object({ + type = string + runner = object({ + config_table_name = optional(string, null) + runner_state_table_name = optional(string, null) + scope = optional(string, null) + iam_policy_json = optional(string, null) + }) + }) + default = { + type = "aws_ssm" + runner = { + config_table_name = null + runner_state_table_name = null + scope = null + iam_policy_json = null + } + } + + validation { + condition = contains(["aws_ssm", "aws_dynamodb"], var.storage_provider.type) + error_message = "storage_provider.type must be aws_ssm or aws_dynamodb." + } + + validation { + condition = var.storage_provider.type != "aws_dynamodb" || ( + var.storage_provider.runner.config_table_name != null && + var.storage_provider.runner.runner_state_table_name != null && + var.storage_provider.runner.scope != null && + var.storage_provider.runner.iam_policy_json != null + ) + error_message = "aws_dynamodb storage requires non-null runner config table, runner-state table, bootstrap scope, and IAM policy capabilities." + } +} + variable "observability" { description = <<-EOT CloudWatch Logs settings available to compute-provider runner log groups. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 6f01f1b345..455541024f 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,39 +167,41 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | -| [random](#provider\_random) | ~> 3.0 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | +| [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | ## 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 | | [runner\_configs](#module\_runner\_configs) | ../runner-config | n/a | | [runners](#module\_runners) | ../runners | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | +| [storage\_aws\_dynamodb](#module\_storage\_aws\_dynamodb) | ../storage-providers/aws/dynamodb | 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 | | [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [terraform_data.validate_experimental](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [aws_caller_identity.storage](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [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 | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -215,7 +217,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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null.
- `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider`: Shared compute-provider defaults grouped first by cloud and then by provider type. Global defaults do not select a provider for any runner configuration.
- `compute_provider.selections`: Optional plan-shaping map keyed by runner-configuration key. Each entry identifies the namespace and type of the configuration's selected compute-provider block. The default is null, which discovers selections from the typed provider blocks. Set this map when unrelated apply-time values make that discovery unknown; its keys and values must be known during planning and cover every runner configuration exactly once.
- `compute_provider.selections[].namespace`: Compute-provider namespace. The only currently supported value is `aws`.
- `compute_provider.selections[].type`: Compute-provider type within the namespace. The only currently supported value is `ec2`.
- `compute_provider.aws`: Shared defaults for AWS compute providers.
- `compute_provider.aws.ec2`: Shared defaults for AWS EC2 runner configurations.
- `compute_provider.aws.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.aws.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.aws.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.aws.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.aws.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.aws.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.aws.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.aws.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.aws.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.aws.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.aws.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.aws.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.aws.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.aws.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.aws.ec2.runner_binaries.targets`: Optional plan-shaping map of shared runner-distribution targets, keyed by `_`. The default is null, which discovers enabled targets from runner configurations. Set this map when unrelated apply-time values make that discovery unknown. An empty map creates no shared binary syncers; every enabled runner platform must have a corresponding entry.
- `compute_provider.aws.ec2.runner_binaries.targets[].os`: Runner operating system for the target. Valid values are `linux`, `osx`, and `windows`.
- `compute_provider.aws.ec2.runner_binaries.targets[].architecture`: Runner distribution architecture for the target. Valid values are `x64` and `arm64`.
- `compute_provider.aws.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.aws.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.aws.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.aws.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.aws.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].compute_provider`: Typed compute-provider namespaces. Exactly one nested provider block must be non-null, and that populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply.
- `multi_runner_config[].compute_provider.aws`: AWS compute-provider namespace. The namespace itself does not select a provider.
- `multi_runner_config[].compute_provider.aws.ec2`: AWS EC2-specific configuration. A non-null block selects AWS EC2 for this runner configuration.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.aws.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.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.
- `multi_runner_config[].compute_provider.aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.aws.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.aws.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.aws.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.aws.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.aws.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.aws.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.aws.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.aws.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].compute_provider.aws.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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
targets = optional(map(object({
os = string
architecture = string
})), null)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

compute_provider = object({
aws = optional(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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
}), {})
})

})), {})
})
| `{}` | 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.

Set `experimental.multi_runner_config` to opt into provider-oriented runner configurations. A non-empty experimental map completely replaces the stable top-level `multi_runner_config`; an empty map keeps stable entries on the unchanged `runners` module.

Sibling blocks provide global v2 defaults. Global and per-configuration values use `configuration override > experimental global v2 default` precedence. Migrated v2 consumers do not inherit a matching flat module input. Singleton shared components consume the global translated values documented below; per-configuration overrides affect only their runner configuration. Component-specific inputs without a nested counterpart continue consuming their existing flat inputs. Tag maps merge from broad to narrow instead of replacing the broader map.
Nullable per-configuration values inherit their experimental global value, so only defaults that apply to every runner configuration should be placed in a global block. When a runner configuration selects an external `runner.iam.role`, inherited managed policies and additional trust policy JSON are intentionally suppressed because this module does not manage that role.
Plan-shaping ownership wrappers remain nullable where their object presence controls Terraform graph shape. `ssm.kms_key_id` is instead an ARN-valued scalar; provider-owned IAM omits null KMS statements while still accepting an ARN whose value is unknown until apply. The unchanged shared webhook retains its legacy policy handling.

Global experimental fields support the following nested properties. These values apply directly when `experimental.multi_runner_config` is non-empty; in stable mode, flat inputs are translated into the same global shape.

- `tags`: Base tags for v2 build queues, runner configurations, the shared GitHub App Parameter Store module, webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `roles.path`: Default IAM path for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `roles.permissions_boundary`: Default permissions-boundary ARN for module-managed v2 runner and Lambda roles and for the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is null.
- `runner.os`: Default runner operating system. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.architecture`: Default runner distribution architecture. The default is null; every runner configuration must resolve this field globally or locally.
- `runner.disable_default_labels`: Omits the default self-hosted, operating-system, and architecture labels when true. The default is `false`.
- `runner.extra_labels`: Default additional labels combined with each runner configuration's matcher labels. The default is `[]`.
- `runner.group_name`: Default GitHub runner group. The default is `Default`.
- `runner.name_prefix`: Default prefix added to registered runner names. The default is an empty string.
- `runner.run_as_root`: Runs the runner service as root when supported by the provider. The default is `false`.
- `runner.run_as`: Default operating-system user when `run_as_root` is false. The default is `ec2-user`.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater. The default is `false`.
- `runner.tags`: Default tags for common runner resources, currently the module-managed runner IAM role. The default is `{}`.
- `runner.hooks.job_started`: Default script content installed as the runner job-started hook. The default is an empty string.
- `runner.hooks.job_completed`: Default script content installed as the runner job-completed hook. The default is an empty string.
- `runner.iam.role`: Optional externally managed runner-role wrapper. The default is null; wrapper presence selects external role ownership during planning.
- `runner.iam.role.arn`: ARN of the externally managed runner role.
- `runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to module-managed runner roles. The default is `{}`. Keep this map empty when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited map.
- `runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the provider trust policy for module-managed runner roles. The default is null. Keep it null when the global `runner.iam.role` selects an external role; a runner configuration that explicitly selects its own external role suppresses the inherited value.
- `runner.iam.path`: Runner-role IAM path. The default is null, which falls back to `roles.path`.
- `runner.iam.permissions_boundary`: Runner-role permissions-boundary ARN. The default is null, which falls back to `roles.permissions_boundary`.
- `github.app`: Primary GitHub App credentials persisted or selected by the shared Parameter Store module and used by v2 runner configurations. The default is null, but a non-empty v2 map requires this object.
- `github.app.key_base64`: Base64-encoded GitHub App private key supplied directly.
- `github.app.key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.app.key_base64_ssm.arn`: ARN of the existing private-key parameter.
- `github.app.key_base64_ssm.name`: Name of the existing private-key parameter.
- `github.app.id`: GitHub App ID supplied directly.
- `github.app.id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.app.id_ssm.arn`: ARN of the existing app-ID parameter.
- `github.app.id_ssm.name`: Name of the existing app-ID parameter.
- `github.app.webhook_secret`: GitHub App webhook secret supplied directly.
- `github.app.webhook_secret_ssm`: Existing Parameter Store webhook-secret parameter wrapper. Set this or `webhook_secret`.
- `github.app.webhook_secret_ssm.arn`: ARN of the existing webhook-secret parameter.
- `github.app.webhook_secret_ssm.name`: Name of the existing webhook-secret parameter.
- `github.additional_apps`: Additional GitHub App credentials persisted or selected by the shared Parameter Store module and used for API request distribution. The default is `[]`.
- `github.additional_apps[].key_base64`: Base64-encoded private key supplied directly for an additional app.
- `github.additional_apps[].key_base64_ssm`: Existing Parameter Store private-key parameter wrapper. Set this or `key_base64`.
- `github.additional_apps[].key_base64_ssm.arn`: ARN of the existing additional-app private-key parameter.
- `github.additional_apps[].key_base64_ssm.name`: Name of the existing additional-app private-key parameter.
- `github.additional_apps[].id`: Additional GitHub App ID supplied directly.
- `github.additional_apps[].id_ssm`: Existing Parameter Store app-ID parameter wrapper. Set this or `id`.
- `github.additional_apps[].id_ssm.arn`: ARN of the existing additional-app ID parameter.
- `github.additional_apps[].id_ssm.name`: Name of the existing additional-app ID parameter.
- `github.additional_apps[].installation_id`: Optional installation ID supplied directly for an additional app.
- `github.additional_apps[].installation_id_ssm`: Optional existing Parameter Store installation-ID parameter wrapper.
- `github.additional_apps[].installation_id_ssm.arn`: ARN of the existing installation-ID parameter.
- `github.additional_apps[].installation_id_ssm.name`: Name of the existing installation-ID parameter.
- `github.enterprise_server.url`: GitHub Enterprise Server URL used by v2 runner-config GitHub clients and the shared termination watcher. The default is null.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for v2 runner-config GitHub clients. The default is `true`.
- `github.user_agent`: HTTP User-Agent used by v2 runner-config GitHub clients. The default is `github-aws-runners`.
- `lambda.artifact.s3.bucket`: Optional shared S3 bucket containing Lambda deployment artifacts for v2 runner configurations and their SSM housekeepers, the webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null. A component selects an object from this bucket only when its own `artifact.s3` wrapper is present.
- `lambda.runtime`: Runtime for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `nodejs24.x`.
- `lambda.architecture`: Architecture for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `arm64`.
- `lambda.principals`: Additional principals allowed to assume v2 runner-config, runner-binary syncer, termination-watcher, and AMI-housekeeper Lambda roles. The default is `[]`; list membership must be known during planning because it creates IAM principal blocks.
- `lambda.principals[].type`: IAM principal type.
- `lambda.principals[].identifiers`: IAM principal identifiers for the type.
- `lambda.subnet_ids`: Subnets for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.security_group_ids`: Security groups for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `[]`.
- `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`.
- `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`.
- `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`.
- `storage_provider`: Global runner-configuration storage selection. It applies to every v2 runner entry and cannot be overridden per entry. When omitted, the existing SSM provider remains selected.
- `storage_provider.aws.dynamodb`: Selects two shared DynamoDB tables for the whole multi-runner deployment: `-config` for durable global and entry configuration, and `-runner-state` for TTL-backed runner configuration and lifecycle records.
- `storage_provider.aws.dynamodb.config`: Durable table encryption, point-in-time recovery, deletion protection, and tags.
- `storage_provider.aws.dynamodb.runner_state`: Ephemeral table encryption, recovery, deletion protection, TTL durations, and tags.
- `storage_provider.aws.dynamodb.runner_state.runner_config_ttl_seconds`: Lifetime for one-time runner registration and JIT configuration records. The default is 86400 seconds.
- `storage_provider.aws.dynamodb.runner_state.runner_state_ttl_seconds`: Safety retention applied only to stale provisioning and terminating lifecycle records. Active and orphan inventory does not expire. The default is 604800 seconds.
- `storage_provider.aws.ssm`: Retains the existing Parameter Store storage backend for v2. Select this when GitHub App inputs reference externally managed `*_ssm` parameters.
- `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null.
- `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job.
- `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation.
- `orchestration_provider.webhook.eventbridge.accept_events`: EventBridge event types accepted by the shared webhook. The default is `[]`, which accepts all supported events.
- `orchestration_provider.webhook.matcher_config_parameter_store_tier`: Parameter Store tier for the shared matcher configuration. The default is `Standard`; valid values are `Standard` and `Advanced`. The value must be known during planning because it determines the matcher-parameter chunks.
- `orchestration_provider.webhook.github.repository_white_list`: Repository full names allowed to use the shared webhook. The default is `[]`, which disables repository filtering.
- `orchestration_provider.webhook.runner.boot_time_in_minutes`: Default expected runner boot duration used by webhook scale-down and pool controls. The default is `5`.
- `orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode by default. The default is `false`.
- `orchestration_provider.webhook.runner.jit_config_enabled`: Explicit default for just-in-time runner configuration. The default is null, which follows the resolved `ephemeral` mode.
- `orchestration_provider.webhook.runner.maximum_count`: Default maximum number of runners managed by the webhook orchestration provider per runner configuration. The default is null; every webhook runner configuration must resolve this field globally or locally.
- `orchestration_provider.webhook.lambda.artifact`: Shared runner-control-plane artifact used by webhook scale, pool, and job-retry components. Set at most one of `zip` or `s3`; when both are null, the packaged runner archive is used.
- `orchestration_provider.webhook.lambda.artifact.zip`: Optional local path to the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for runner-control-plane Lambdas, must be known during planning, and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.artifact.s3.key`: Object key of the shared runner-control-plane Lambda archive.
- `orchestration_provider.webhook.lambda.artifact.s3.object_version`: Optional object version of the shared runner-control-plane Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.scale.up.memory_size`: Scale-up Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `30`.
- `orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum build-queue batching window. The default is `0`.
- `orchestration_provider.webhook.lambda.scale.up.tags`: Default tags for scale-up resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.scale.down.memory_size`: Scale-down Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule for scale-down. The default is `cron(*/5 * * * ? *)`.
- `orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `orchestration_provider.webhook.lambda.scale.down.idle_config`: Default time-based desired idle-runner configurations. The default is `[]`.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `orchestration_provider.webhook.lambda.scale.down.tags`: Default tags for scale-down resources. The default is `{}`.
- `orchestration_provider.webhook.lambda.webhook.artifact`: Shared-webhook artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `orchestration_provider.webhook.lambda.webhook.artifact.zip`: Optional local path to the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the webhook and requires a non-null shared bucket and key.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.key`: Object key of the shared-webhook Lambda archive.
- `orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version`: Optional object version of the shared-webhook Lambda archive. The default is null.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings`: Optional API Gateway access-log destination and format for the shared webhook. The default is null, and wrapper presence must be known during planning because it controls the access-log block.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn`: CloudWatch Logs destination ARN for API Gateway access logs.
- `orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format`: API Gateway access-log format.
- `orchestration_provider.webhook.lambda.webhook.memory_size`: Shared-webhook Lambda memory in MB. The default is `256`.
- `orchestration_provider.webhook.lambda.webhook.timeout`: Shared-webhook Lambda timeout in seconds. The default is `10`.
- `orchestration_provider.webhook.lambda.webhook.tags`: Additional tags for the shared webhook Lambda, merged after `lambda.tags`. The default is `{}`.
- `orchestration_provider.webhook.lambda.pool.memory_size`: Pool Lambda memory in MB. The default is `512`.
- `orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `orchestration_provider.webhook.lambda.pool.config`: Default scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `orchestration_provider.webhook.lambda.pool.config[].size`: Desired runner-pool size for the schedule.
- `orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `orchestration_provider.webhook.lambda.pool.tags`: Default tags for pool resources. The default is `{}`.
- `orchestration_provider.webhook.queue.delay_webhook_event`: Default delay in seconds applied to accepted webhook jobs. The default is `30`.
- `orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Default build-queue message retention period in seconds. The default is `86400`.
- `orchestration_provider.webhook.queue.visibility_timeout_seconds`: Default build-queue visibility timeout. The default is `180`; set it to at least six times every resolved `orchestration_provider.webhook.lambda.scale.up.timeout` that inherits it.
- `orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue to every v2 build queue by default. The default is `false`.
- `orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Default number of receives before a message moves to the dead-letter queue. The default is null while redrive is disabled and must resolve to a value greater than zero when redrive is enabled.
- `orchestration_provider.webhook.queue.tags`: Default tags for v2 build queues and dead-letter queues. The default is `{}`.
- `orchestration_provider.webhook.queue.encryption`: Global at-rest encryption configuration for the multi-runner build queues and their dead-letter queues. It does not configure runner-config job-retry queues. Omitting the whole block selects SQS-managed encryption and defaults the two KMS attributes to null. When supplying the block explicitly, provide all three leaf attributes and use null for the inactive mode.
- `orchestration_provider.webhook.queue.encryption.kms_data_key_reuse_period_seconds`: KMS data-key reuse period in seconds. This key is required syntactically in an explicit `queue.encryption` object but may be null; it is used only with `kms_master_key_id`.
- `orchestration_provider.webhook.queue.encryption.kms_master_key_id`: KMS key ARN used for queue encryption. This AWS-facing field name is retained for compatibility, but non-null values must be key ARNs because runner-config IAM policies cannot use key IDs or aliases as resources. The field is required syntactically in an explicit `queue.encryption` object but may be null, and a computed ARN may remain unknown until apply. It is independent from `ssm.kms_key_id` and is forwarded separately to each webhook runner configuration so runner-config scale-up and job-retry roles receive only the build-queue KMS permissions they require.
- `orchestration_provider.webhook.queue.encryption.sqs_managed_sse_enabled`: Selects the non-KMS mode: `true` enables SQS-managed encryption and `false` explicitly disables queue encryption. This key is required syntactically in an explicit `queue.encryption` object and must be null when `kms_master_key_id` is set. Omitting the whole encryption block defaults it to `true`.
- `ssm.paths.root`: Base Parameter Store path for shared GitHub App and webhook parameters and for all v2 runner configurations. The schema default is null, which derives `/github-action-runners/`; normalization appends the configuration key only for configuration-owned paths.
- `ssm.paths.app`: Shared GitHub App credential path segment below `ssm.paths.root`. The default is `app`.
- `ssm.paths.webhook`: Shared webhook matcher-configuration path segment below `ssm.paths.root`. The default is `webhook`.
- `ssm.paths.tokens`: Runner registration-token and JIT-configuration path segment below each runner-configuration root. The default is `runners/tokens`.
- `ssm.paths.config`: Persistent runner-configuration path segment below each runner-configuration root. The default is `runners/config`.
- `ssm.kms_key_id`: Optional global KMS key ARN that encrypts shared GitHub App parameters, configures the webhook and termination watcher, and adds matching decrypt permissions to every v2 runner configuration. The default is null and its value may be unknown until apply. It does not select encryption for runtime-created runner-configuration parameters.
- `ssm.tags`: Default tags for the shared GitHub App Parameter Store module and runner-configuration-owned SSM resources. The default is `{}`.
- `ssm.parameters.tags`: Default tags for Terraform-managed and runtime-created runner-configuration parameters. The default is `{}`.
- `ssm.housekeeper.schedule_expression`: Default EventBridge schedule for each runner-configuration SSM housekeeper. The default is `rate(1 day)`.
- `ssm.housekeeper.state`: Default EventBridge rule state for each runner-configuration SSM housekeeper. The default is `ENABLED`.
- `ssm.housekeeper.tags`: Default tags for SSM housekeeper resources. The default is `{}`.
- `ssm.housekeeper.lambda.artifact`: Default SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, runner-config uses its packaged runner control-plane archive.
- `ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for the SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. The default is null.
- `ssm.housekeeper.lambda.memory_size`: Default SSM housekeeper Lambda memory in MB. The default is `512`.
- `ssm.housekeeper.lambda.timeout`: Default SSM housekeeper Lambda timeout in seconds. The default is `60`.
- `ssm.housekeeper.config.tokenPath`: Optional cleanup path shared by every runner configuration. The default is null; omit it so each runner configuration derives its isolated token path.
- `ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. The default is `1`.
- `ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. The default is `false`.
- `observability.logs.level`: Application log level for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `info`.
- `observability.logs.retention_in_days`: CloudWatch Logs retention for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `180`.
- `observability.logs.kms_key_id`: Optional KMS key ID or ARN for v2 runner-config log groups and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null.
- `observability.logs.class`: CloudWatch log-group class for v2 runner-config resources and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `STANDARD`.
- `observability.logs.tags`: Default tags for v2 runner-config log groups. The default is `{}`; shared singleton functions receive `tags` and `lambda.tags` instead.
- `observability.tracing.mode`: Optional Lambda tracing mode for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is null; its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.tracing.capture_error`: Enables error capture in the tracing helper for v2 runner-config functions and translated shared consumers. The default is `false`.
- `observability.metrics.enable`: Enables module-emitted metrics for v2 runner configurations and the shared termination watcher. The default is `false`.
- `observability.metrics.namespace`: CloudWatch namespace for v2 runner-config and termination-watcher metrics. The default is `GitHub Runners`.
- `observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_job_retry`: Emits job-retry metrics when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination`: Emits Spot termination metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `observability.metrics.metric.enable_spot_termination_warning`: Emits Spot termination-warning metrics from the shared termination watcher when metrics are enabled. The default is `true`.
- `compute_provider`: Shared compute-provider defaults grouped first by cloud and then by provider type. Global defaults do not select a provider for any runner configuration.
- `compute_provider.selections`: Optional plan-shaping map keyed by runner-configuration key. Each entry identifies the namespace and type of the configuration's selected compute-provider block. The default is null, which discovers selections from the typed provider blocks. Set this map when unrelated apply-time values make that discovery unknown; its keys and values must be known during planning and cover every runner configuration exactly once.
- `compute_provider.selections[].namespace`: Compute-provider namespace. The only currently supported value is `aws`.
- `compute_provider.selections[].type`: Compute-provider type within the namespace. The only currently supported value is `ec2`.
- `compute_provider.aws`: Shared defaults for AWS compute providers.
- `compute_provider.aws.ec2`: Shared defaults for AWS EC2 runner configurations.
- `compute_provider.aws.ec2.vpc_id`: Shared VPC default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.subnet_ids`: Shared subnet default for v2 EC2 runner configurations. The default is null; every EC2 runner configuration must resolve this field globally or locally.
- `compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group by default. The default is `true`.
- `compute_provider.aws.ec2.egress_rules`: Shared runner security-group egress rules. The default is one IPv4/IPv6 allow-all rule; v2 does not inherit flat `runner_egress_rules`.
- `compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].from_port`: Start of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].protocol`: Egress rule protocol; `-1` allows every protocol.
- `compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for an egress rule.
- `compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `compute_provider.aws.ec2.egress_rules[].to_port`: End of the egress rule port range.
- `compute_provider.aws.ec2.egress_rules[].description`: Optional egress rule description.
- `compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to every v2 EC2 runner configuration unless overridden. The default is `[]`.
- `compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration inherited by v2 EC2 runner configurations. The default is null; enablement remains configuration-owned.
- `compute_provider.aws.ec2.instance_profile_path`: IAM path for module-managed EC2 instance profiles. The default is null.
- `compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name inherited by v2 EC2 runner configurations. The default is null.
- `compute_provider.aws.ec2.associate_public_ipv4_address`: Associates public IPv4 addresses with v2 EC2 runners unless overridden. The default is `false`.
- `compute_provider.aws.ec2.tags`: Default tags for runtime EC2 resources. The default is `{}` and runner-configuration EC2 tags take precedence.
- `compute_provider.aws.ec2.ami.housekeeper`: Global configuration for the shared AMI-housekeeper Lambda.
- `compute_provider.aws.ec2.ami.housekeeper.enabled`: Creates the shared AMI housekeeper when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config`: AMI cleanup selection and safety settings. The default is `{}`, which resolves the leaf defaults described below in the AMI-housekeeper module.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.maxItems`: Optional maximum number of AMIs queried for cleanup. The default is null, which applies no maximum.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.minimumDaysOld`: Minimum AMI age in days before cleanup. The effective default is `30`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.amiFilters`: AMI filters, each containing `Name` and `Values`. The effective default selects images with `state = available` and `image-type = machine`.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.launchTemplateNames`: Optional launch-template names whose referenced AMIs are retained. The default is null, which selects no launch templates.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.ssmParameterNames`: Optional Parameter Store names whose referenced AMIs are retained. The default is null, which selects no parameters.
- `compute_provider.aws.ec2.ami.housekeeper.cleanup_config.dryRun`: Reports eligible AMIs without deregistering them when true. The effective default is `false`.
- `compute_provider.aws.ec2.ami.housekeeper.artifact`: AMI-housekeeper artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.zip`: Optional local path to the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.key`: Object key of the AMI-housekeeper Lambda archive.
- `compute_provider.aws.ec2.ami.housekeeper.artifact.s3.object_version`: Optional object version of the AMI-housekeeper Lambda archive. The default is null.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.memory_size`: AMI-housekeeper Lambda memory in MB. The default is `256`.
- `compute_provider.aws.ec2.ami.housekeeper.lambda.timeout`: AMI-housekeeper Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.ami.housekeeper.schedule.expression`: AMI-housekeeper EventBridge schedule expression. The default is `cron(11 7 * * ? *)`.
- `compute_provider.aws.ec2.instance_termination_watcher`: Global configuration for the shared EC2 instance-termination watcher.
- `compute_provider.aws.ec2.instance_termination_watcher.enabled`: Creates the shared EC2 termination watcher when true. The default is `false`, and the value must be known during planning because it controls the module instance.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_handler`: Enables the Spot termination-event handler. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.features.enable_spot_termination_notification_watcher`: Enables the Spot interruption-warning watcher. The default is `true`, and the value must be known during planning because it controls child resources.
- `compute_provider.aws.ec2.instance_termination_watcher.enable_runner_deregistration`: Deregisters terminated runners from GitHub when true. The default is `true`, and the value must be known during planning because it controls deregistration resources.
- `compute_provider.aws.ec2.instance_termination_watcher.environment_variables`: Additional termination-watcher Lambda environment variables. The default is `{}`.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact`: Termination-watcher artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.zip`: Optional local path to the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.key`: Object key of the termination-watcher Lambda archive.
- `compute_provider.aws.ec2.instance_termination_watcher.artifact.s3.object_version`: Optional object version of the termination-watcher Lambda archive. The default is null.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.memory_size`: Optional watcher Lambda memory in MB. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.instance_termination_watcher.lambda.timeout`: Optional watcher Lambda timeout in seconds. The default is null, which delegates to the termination-watcher module.
- `compute_provider.aws.ec2.runner_binaries`: Global configuration for the shared runner-distribution buckets and syncers, created once per unique enabled runner operating-system and architecture pair.
- `compute_provider.aws.ec2.runner_binaries.enabled`: Default for whether EC2 runner configurations use the synchronized runner distribution. The default is `true`; a runner configuration may override it through `compute_provider.aws.ec2.binaries_syncer.enabled`. Every resolved enable value must be known during planning because it determines the syncer module instances.
- `compute_provider.aws.ec2.runner_binaries.targets`: Optional plan-shaping map of shared runner-distribution targets, keyed by `_`. The default is null, which discovers enabled targets from runner configurations. Set this map when unrelated apply-time values make that discovery unknown. An empty map creates no shared binary syncers; every enabled runner platform must have a corresponding entry.
- `compute_provider.aws.ec2.runner_binaries.targets[].os`: Runner operating system for the target. Valid values are `linux`, `osx`, and `windows`.
- `compute_provider.aws.ec2.runner_binaries.targets[].architecture`: Runner distribution architecture for the target. Valid values are `x64` and `arm64`.
- `compute_provider.aws.ec2.runner_binaries.s3`: Settings for each shared runner-distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption`: Server-side encryption settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.enabled`: Creates an explicit distribution-bucket encryption configuration when true. The default is `true`, and the value must be known during planning because it controls resource shape. Keep `kms_master_key_id` null when this is false.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.bucket_key_enabled`: Optional S3 Bucket Key setting. The default is null.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.sse_algorithm`: Server-side encryption algorithm. The default is `AES256`; valid values are `AES256`, `aws:kms`, and `aws:kms:dsse`. When `kms_master_key_id` is set, use one of the KMS algorithms.
- `compute_provider.aws.ec2.runner_binaries.s3.encryption.kms_master_key_id`: Optional KMS key identifier for the distribution bucket. The default is null, and its nullness must be known during planning because it controls the syncer KMS policy. The syncer receives KMS access, but runner roles do not derive `kms:Decrypt` from this field; grant runner roles decrypt access separately when using a CMK.
- `compute_provider.aws.ec2.runner_binaries.s3.tags`: Additional tags for each distribution bucket. The default is `{}`; these merge after global `tags`.
- `compute_provider.aws.ec2.runner_binaries.s3.versioning`: Distribution-bucket versioning state. The default is `Disabled`; valid values are `Disabled`, `Enabled`, and `Suspended`. After enabling versioning, Terraform cannot return the bucket to `Disabled`; use `Suspended` instead.
- `compute_provider.aws.ec2.runner_binaries.s3.logging`: Optional access-logging settings for each distribution bucket.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.bucket`: Existing target bucket for access logs. The default is null, and its nullness must be known during planning because it controls the logging resource.
- `compute_provider.aws.ec2.runner_binaries.s3.logging.prefix`: Optional access-log prefix. The default is null, which uses the distribution-bucket name when logging is enabled. A non-null prefix requires `logging.bucket`.
- `compute_provider.aws.ec2.runner_binaries.syncer`: Component-specific settings for the shared runner-binary syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact`: Syncer Lambda artifact selection. Set at most one of `zip` or `s3`; when both are null, the packaged archive is used.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip`: Optional local path to the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 and requires a non-null shared bucket and key.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.key`: Object key of the syncer Lambda archive. Required when the `s3` wrapper is present.
- `compute_provider.aws.ec2.runner_binaries.syncer.artifact.s3.object_version`: Optional object version of the syncer Lambda archive. The default is null.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda`: Syncer Lambda sizing settings. Runtime, architecture, networking, role, tags, logging, and tracing come from their global experimental blocks.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.memory_size`: Memory allocated to the syncer Lambda in MB. The default is `256`.
- `compute_provider.aws.ec2.runner_binaries.syncer.lambda.timeout`: Syncer Lambda timeout in seconds. The default is `300`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule`: EventBridge schedule settings for the syncer Lambda.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.expression`: EventBridge schedule expression. The default is `cron(27 * * * ? *)`.
- `compute_provider.aws.ec2.runner_binaries.syncer.schedule.state`: EventBridge rule state. The default is `ENABLED`; valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.

Each `experimental.multi_runner_config` entry supports the following nested fields:

- `multi_runner_config[].tags`: Configuration-wide tags. These override global `experimental.tags`; narrower component and compute-provider tag maps take precedence for their resources. Flat `tags` are not merged into v2 queues or runner configurations.
- `multi_runner_config[].runner.os`: Runner operating system.
- `multi_runner_config[].runner.architecture`: Runner distribution architecture.
- `multi_runner_config[].runner.disable_default_labels`: Prevents GitHub default labels from being registered.
- `multi_runner_config[].runner.extra_labels`: Additional labels combined with `orchestration_provider.webhook.matcherConfig.labelMatchers` for webhook runner configurations. Default self-hosted, operating-system, and architecture labels are also included unless `runner.disable_default_labels` is true.
- `multi_runner_config[].runner.group_name`: GitHub runner group used during registration.
- `multi_runner_config[].runner.name_prefix`: Prefix added to registered runner names.
- `multi_runner_config[].runner.run_as_root`: Runs the runner service as root when supported by the compute provider.
- `multi_runner_config[].runner.run_as`: Operating-system user used when `run_as_root` is false.
- `multi_runner_config[].runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `multi_runner_config[].runner.tags`: Tags for common runner resources, currently the managed runner IAM role. These override entry-level `tags`.
- `multi_runner_config[].runner.hooks.job_started`: Script content installed as the runner job-started hook.
- `multi_runner_config[].runner.hooks.job_completed`: Script content installed as the runner job-completed hook.
- `multi_runner_config[].runner.iam.role`: Optional externally managed runner-role wrapper. When set, inherited managed policies and trust additions are suppressed.
- `multi_runner_config[].runner.iam.role.arn`: ARN of an externally managed runner role. When set, `runner-config` does not create or modify that role.
- `multi_runner_config[].runner.iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. Keep this empty or null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. Keep it null when the runner configuration selects an external `runner.iam.role`.
- `multi_runner_config[].runner.iam.path`: IAM path for the module-managed runner role.
- `multi_runner_config[].runner.iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role.
- `multi_runner_config[].lambda`: Lambda runtime, architecture, networking, tags, and role substrate shared by orchestration and the runner-configuration SSM housekeeper. Scale function settings belong under `orchestration_provider.webhook.lambda`.
- `multi_runner_config[].lambda.runtime`: Per-configuration runtime override for runner-config Lambda functions.
- `multi_runner_config[].lambda.architecture`: Per-configuration architecture override for runner-config Lambda functions.
- `multi_runner_config[].lambda.subnet_ids`: Per-configuration subnet override for runner-config Lambda functions.
- `multi_runner_config[].lambda.security_group_ids`: Per-configuration security-group override for runner-config Lambda functions.
- `multi_runner_config[].lambda.tags`: Per-configuration tags for control-plane Lambda functions. Component tags override this map.
- `multi_runner_config[].lambda.role.path`: Per-configuration IAM path for module-managed Lambda roles. Null inherits `experimental.lambda.role.path`, then `experimental.roles.path`.
- `multi_runner_config[].lambda.role.permissions_boundary`: Per-configuration permissions-boundary ARN for module-managed Lambda roles. Null inherits `experimental.lambda.role.permissions_boundary`, then `experimental.roles.permissions_boundary`.
- `multi_runner_config[].orchestration_provider`: Demand-controller selection. Exactly one typed provider block must be non-null. Only `webhook` is supported today; additional providers can be added without moving the webhook contract.
- `multi_runner_config[].orchestration_provider.webhook`: Selects the workflow-job webhook control plane, including its SQS build queue, scale-up, scheduled scale-down/pool, and optional job-retry resources.
- `multi_runner_config[].orchestration_provider.webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by webhook scale-down and pool controls. Null inherits `experimental.orchestration_provider.webhook.runner.boot_time_in_minutes`.
- `multi_runner_config[].orchestration_provider.webhook.runner.ephemeral`: Registers webhook-orchestrated runners in ephemeral mode. Null inherits `experimental.orchestration_provider.webhook.runner.ephemeral`.
- `multi_runner_config[].orchestration_provider.webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null inherits the global webhook value; if both are null, behavior follows the resolved webhook `ephemeral` mode.
- `multi_runner_config[].orchestration_provider.webhook.runner.maximum_count`: Maximum number of runners managed by the webhook orchestration provider for this configuration. Null inherits `experimental.orchestration_provider.webhook.runner.maximum_count`.
- `multi_runner_config[].orchestration_provider.webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise repository-scoped registration is used.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.labelMatchers`: Groups of labels used to match webhook jobs to this configuration.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.exactMatch`: Deprecated one-way match. When true, every workflow-job label must appear in a configured label group, but that group may contain additional labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch`: Requires an exact two-way set match between workflow-job labels and a configured label group, with no extra or missing labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.priority`: Ordering used when multiple configurations match the same job.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.enableDynamicLabels`: Enables runtime interpretation of supported dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy`: Optional policy restricting values accepted from dynamic AWS labels.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys`: Dynamic-label keys rejected for this runner configuration. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys`: Per-key allow, deny, and maximum-value restrictions. The default is `{}`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..allowed`: Values explicitly allowed for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..denied`: Values explicitly denied for the dynamic-label key. The default is `[]`.
- `multi_runner_config[].orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.restricted_keys..max`: Optional maximum accepted value for the dynamic-label key. The default is null.
- `multi_runner_config[].orchestration_provider.webhook.queue.delay_webhook_event`: Delay in seconds applied to webhook job messages. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.job_queue_retention_in_seconds`: Build-queue message retention period in seconds. Null inherits the global queue default.
- `multi_runner_config[].orchestration_provider.webhook.queue.visibility_timeout_seconds`: Build-queue visibility timeout. Null inherits the global queue default; the resolved value must be at least six times the resolved `orchestration_provider.webhook.lambda.scale.up.timeout` so Lambda has enough time to retry throttled invocations.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.enabled`: Creates and attaches a dead-letter queue for the build queue. A null wrapper or null leaf inherits the corresponding global value.
- `multi_runner_config[].orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount`: Number of receives before a build message moves to the dead-letter queue. A null wrapper or null leaf inherits the corresponding global value, and the resolved value must be greater than zero when redrive is enabled.
- `multi_runner_config[].orchestration_provider.webhook.queue.tags`: Tags for configuration-owned queue resources. These merge after global queue tags and entry-level `tags`; component tags override this map.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for the scale-up Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled`: Enables the queued-job verification before scaling. Null inherits the global value; if both are null, behavior follows the resolved runner mode.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered to one scale-up Lambda invocation.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.up.tags`: Tags for scale-up resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes`: Minimum runner age before scale-down may terminate it. Null inherits the global value; if both are null, the operating-system default is selected.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate `cron`.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners to retain during the matching period.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `multi_runner_config[].orchestration_provider.webhook.lambda.scale.down.tags`: Tags for scale-down resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used when creating pooled runners.
- `multi_runner_config[].orchestration_provider.webhook.lambda.pool.tags`: Tags for pool resources. These override entry-level and shared Lambda and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.tags`: Tags for job-retry resources. These override entry-level and shared Lambda, queue, and log-group tags within their resource scopes.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for the job-retry Lambda. Use `-1` for unreserved concurrency.
- `multi_runner_config[].orchestration_provider.webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue.
- `multi_runner_config[].ssm.paths.root`: Base Parameter Store root for this runner configuration. The configuration key is always appended to preserve configuration isolation. The omitted global root derives `/github-action-runners/`.
- `multi_runner_config[].ssm.paths.tokens`: Path segment below the runner-configuration root used for runner registration tokens and just-in-time configuration.
- `multi_runner_config[].ssm.paths.config`: Path segment below the runner-configuration root used for persistent runner configuration.
- `multi_runner_config[].ssm.tags`: Shared tags for runner-configuration-owned SSM resources. These override entry-level `tags`.
- `multi_runner_config[].ssm.parameters.tags`: Tags for Terraform-managed and runtime-created runner configuration parameters. These override `ssm.tags`.
- `multi_runner_config[].ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the runner-configuration SSM housekeeper.
- `multi_runner_config[].ssm.housekeeper.state`: EventBridge rule state for the runner-configuration SSM housekeeper. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
- `multi_runner_config[].ssm.housekeeper.tags`: Tags for SSM housekeeper resources. These override entry-level, shared Lambda, shared log, and `ssm.tags` values.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact`: Per-configuration SSM-housekeeper artifact selection. A selected `zip` or `s3` source overrides the global `ssm.housekeeper.lambda.artifact`; when neither level selects a source, runner-config uses its packaged runner control-plane archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3`: Optional key and object version in the shared `lambda.artifact.s3.bucket`. Wrapper presence selects S3 for this SSM housekeeper, must be known during planning, and requires a non-null shared bucket and key.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.key`: Object key of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of this runner configuration's SSM-housekeeper Lambda archive.
- `multi_runner_config[].ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `multi_runner_config[].ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `multi_runner_config[].ssm.housekeeper.config.tokenPath`: Optional cleanup path. A global value is shared by every runner configuration, so omit it to derive each configuration's isolated token path.
- `multi_runner_config[].ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `multi_runner_config[].ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true.
- `multi_runner_config[].observability.logs.level`: Application log level for runner-configuration control-plane functions.
- `multi_runner_config[].observability.logs.retention_in_days`: CloudWatch Logs retention period for runner-configuration resources.
- `multi_runner_config[].observability.logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner-configuration CloudWatch log groups.
- `multi_runner_config[].observability.logs.class`: CloudWatch log-group class for runner-configuration resources.
- `multi_runner_config[].observability.logs.tags`: Shared tags for runner-configuration CloudWatch log groups. Component tags override this map.
- `multi_runner_config[].observability.tracing.mode`: Optional Lambda tracing mode. Its nullness must be known during planning because it controls X-Ray IAM and tracing blocks.
- `multi_runner_config[].observability.tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `multi_runner_config[].observability.tracing.capture_error`: Enables error capture in the tracing helper.
- `multi_runner_config[].observability.metrics.enable`: Enables module-emitted metrics.
- `multi_runner_config[].observability.metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `multi_runner_config[].observability.metrics.metric.enable_github_app_rate_limit`: Emits GitHub App rate-limit metrics.
- `multi_runner_config[].observability.metrics.metric.enable_job_retry`: Emits job-retry metrics.
- `multi_runner_config[].compute_provider`: Typed compute-provider namespaces. Exactly one nested provider block must be non-null, and that populated block selects the provider. Its presence must be known during planning; values inside it may remain unknown until apply.
- `multi_runner_config[].compute_provider.aws`: AWS compute-provider namespace. The namespace itself does not select a provider.
- `multi_runner_config[].compute_provider.aws.ec2`: AWS EC2-specific configuration. A non-null block selects AWS EC2 for this runner configuration.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `multi_runner_config[].compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `multi_runner_config[].compute_provider.aws.ec2.ami.filter`: EC2 AMI filters combined with the default AMI-name filter.
- `multi_runner_config[].compute_provider.aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. The wrapper's presence selects external ownership at plan time.
- `multi_runner_config[].compute_provider.aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.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.
- `multi_runner_config[].compute_provider.aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for supported volume types.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `multi_runner_config[].compute_provider.aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `multi_runner_config[].compute_provider.aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `multi_runner_config[].compute_provider.aws.ec2.credit_specification`: CPU credit mode for burstable instance types.
- `multi_runner_config[].compute_provider.aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `multi_runner_config[].compute_provider.aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `multi_runner_config[].compute_provider.aws.ec2.binaries_syncer.enabled`: Enables use of the shared synchronized runner distribution from S3. Null inherits `experimental.compute_provider.aws.ec2.runner_binaries.enabled`.
- `multi_runner_config[].compute_provider.aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.enabled`: Enables launch-template user data.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.content`: Optional complete user-data content used instead of rendering a template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `multi_runner_config[].compute_provider.aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `multi_runner_config[].compute_provider.aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select capacity.
- `multi_runner_config[].compute_provider.aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `multi_runner_config[].compute_provider.aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `multi_runner_config[].compute_provider.aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `multi_runner_config[].compute_provider.aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `multi_runner_config[].compute_provider.aws.ec2.additional_security_group_ids`: Existing security groups attached to runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.managed_security_group_enabled`: Creates the module-managed runner security group when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules`: Runner security-group egress rules. Null inherits `experimental.compute_provider.aws.ec2.egress_rules`, whose default is the built-in IPv4/IPv6 allow-all rule. Flat `runner_egress_rules` is not inherited by v2.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].prefix_list_ids`: Prefix-list destinations for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].from_port`: Start of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].protocol`: Runner-configuration egress rule protocol; `-1` allows every protocol.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].security_groups`: Destination security-group IDs for the runner-configuration egress rule.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].to_port`: End of the runner-configuration egress rule port range.
- `multi_runner_config[].compute_provider.aws.ec2.egress_rules[].description`: Optional runner-configuration egress rule description.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile_path`: IAM path for the module-managed EC2 instance profile.
- `multi_runner_config[].compute_provider.aws.ec2.key_name`: Optional EC2 key-pair name for runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner instances.
- `multi_runner_config[].compute_provider.aws.ec2.instance_profile.name`: Name of an externally managed instance profile. Setting it also requires `runner.iam.role`.
- `multi_runner_config[].compute_provider.aws.ec2.enable_on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `multi_runner_config[].compute_provider.aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `multi_runner_config[].compute_provider.aws.ec2.subnet_ids`: Subnets from which scale-up may launch runners. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.vpc_id`: VPC in which runner networking resources are created. A null configuration value inherits the experimental global value.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `multi_runner_config[].compute_provider.aws.ec2.placement.affinity`: Host affinity setting.
- `multi_runner_config[].compute_provider.aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_id`: Placement-group ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.group_name`: Placement-group name.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_id`: Dedicated Host ID.
- `multi_runner_config[].compute_provider.aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `multi_runner_config[].compute_provider.aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `multi_runner_config[].compute_provider.aws.ec2.placement.tenancy`: Instance tenancy.
- `multi_runner_config[].compute_provider.aws.ec2.placement.partition_number`: Placement-group partition number.
- `multi_runner_config[].compute_provider.aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `multi_runner_config[].compute_provider.aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path required for macOS runners.
- `multi_runner_config[].compute_provider.aws.ec2.log_files`: Optional log files collected by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `multi_runner_config[].compute_provider.aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `multi_runner_config[].compute_provider.aws.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. |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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")
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), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

github = optional(object({
app = optional(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
}))
}), null)
additional_apps = optional(list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}), {})

lambda = optional(object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
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)
}), {})
}), {})

storage_provider = optional(object({
aws = optional(object({
dynamodb = optional(object({
config = optional(object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, true)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
}), {})
runner_state = optional(object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, false)
deletion_protection_enabled = optional(bool, false)
runner_config_ttl_seconds = optional(number, 86400)
runner_state_ttl_seconds = optional(number, 604800)
tags = optional(map(string), {})
}), {})
}), null)
ssm = optional(object({}), null)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(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")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
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), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}), {})

observability = optional(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 = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
}), {})

compute_provider = optional(object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(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)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
targets = optional(map(object({
os = string
architecture = string
})), null)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}), {})

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

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

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

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
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)
}), {})
}), {})

}), null)

})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

compute_provider = object({
aws = optional(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, null)
}), {})
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), null)
managed_security_group_enabled = optional(bool, null)
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
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
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)
}), {})
})

})), {})
})
| `{}` | 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
}))
})
| `null` | no | @@ -284,7 +286,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/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 8423f7aefd..c53de1d216 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -68,6 +68,13 @@ locals { } } + storage_provider = { + aws = { + dynamodb = null + ssm = {} + } + } + orchestration_provider = { webhook = { queue_selection_strategy = var.queue_selection_strategy diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index b889c6a467..c046742e1e 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -18,6 +18,14 @@ module "runner_configs" { app_parameters = local.github_app_parameters }) lambda = each.value.lambda + storage_provider = { + type = local.storage_provider_type + scale_up = local.storage_provider_capabilities.entries[each.key].scale_up + scale_down = local.storage_provider_capabilities.entries[each.key].scale_down + pool = local.storage_provider_capabilities.entries[each.key].pool + job_retry = local.storage_provider_capabilities.entries[each.key].job_retry + runner = local.storage_provider_capabilities.entries[each.key].runner + } orchestration_provider = { webhook = each.value.orchestration_provider.webhook == null ? null : { runner = each.value.orchestration_provider.webhook.runner diff --git a/modules/multi-runner/storage-provider.tf b/modules/multi-runner/storage-provider.tf new file mode 100644 index 0000000000..449bc577f6 --- /dev/null +++ b/modules/multi-runner/storage-provider.tf @@ -0,0 +1,160 @@ +locals { + requested_storage_provider_types = compact([ + try(var.experimental.storage_provider.aws.dynamodb, null) != null ? "aws_dynamodb" : "", + try(var.experimental.storage_provider.aws.ssm, null) != null ? "aws_ssm" : "", + ]) + + storage_provider_type = local.use_multi_runner_config_v2 ? ( + length(local.requested_storage_provider_types) == 0 + ? "aws_ssm" + : one(local.requested_storage_provider_types) + ) : "aws_ssm" + + default_dynamodb_storage_provider = { + config = { + kms_key_arn = null + point_in_time_recovery_enabled = true + deletion_protection_enabled = false + tags = {} + } + runner_state = { + kms_key_arn = null + point_in_time_recovery_enabled = false + deletion_protection_enabled = false + runner_config_ttl_seconds = 86400 + runner_state_ttl_seconds = 604800 + tags = {} + } + } + + dynamodb_storage_provider = coalesce( + try(var.experimental.storage_provider.aws.dynamodb, null), + local.default_dynamodb_storage_provider, + ) + + storage_runner_matcher_config_by_key = { + for k, v in local.runner_matcher_config : format("%03d-%s", v.matcherConfig.priority, k) => merge(v, { + key = k + computeProvider = lower(trimspace(v.computeProvider)) + }) + } + storage_runner_matcher_config = [ + for k in sort(keys(local.storage_runner_matcher_config_by_key)) : local.storage_runner_matcher_config_by_key[k] + ] + + dynamodb_global_records = local.storage_provider_type == "aws_dynamodb" ? { + github_app_credentials = sensitive(jsonencode(concat( + [{ + appId = try(tonumber(local.translated_experimental.github.app.id), 0) + privateKeyBase64 = local.translated_experimental.github.app.key_base64 + }], + [for app in local.translated_experimental.github.additional_apps : merge( + { + appId = try(tonumber(app.id), 0) + privateKeyBase64 = app.key_base64 + }, + app.installation_id == null ? {} : { + installationId = try(tonumber(app.installation_id), 0) + }, + )], + ))) + github_webhook_secret = sensitive(local.translated_experimental.github.app.webhook_secret) + runner_matcher_config = jsonencode(local.storage_runner_matcher_config) + } : { + github_app_credentials = sensitive("") + github_webhook_secret = sensitive("") + runner_matcher_config = "" + } + + dynamodb_entry_records = { + for entry_id, entry in local.translated_experimental.multi_runner_config : entry_id => { + run_as = entry.runner.run_as_root ? "root" : entry.runner.run_as + agent_mode = entry.orchestration_provider.webhook.runner.ephemeral ? "ephemeral" : "persistent" + disable_default_labels = entry.runner.disable_default_labels + enable_jit_config = entry.orchestration_provider.webhook.runner.jit_config_enabled + } + if local.storage_provider_type == "aws_dynamodb" + } +} + +data "aws_caller_identity" "storage" { + count = local.storage_provider_type == "aws_dynamodb" ? 1 : 0 +} + +module "storage_aws_dynamodb" { + source = "../storage-providers/aws/dynamodb" + count = local.storage_provider_type == "aws_dynamodb" ? 1 : 0 + + prefix = var.prefix + tags = merge( + local.translated_experimental.tags, + { "ghr:environment" = var.prefix }, + ) + config = { + config = local.dynamodb_storage_provider.config + runner_state = { + kms_key_arn = local.dynamodb_storage_provider.runner_state.kms_key_arn + point_in_time_recovery_enabled = local.dynamodb_storage_provider.runner_state.point_in_time_recovery_enabled + deletion_protection_enabled = local.dynamodb_storage_provider.runner_state.deletion_protection_enabled + tags = local.dynamodb_storage_provider.runner_state.tags + } + } + entry_ids = keys(local.translated_experimental.multi_runner_config) + runner_config_access_scope_prefixes = { + for entry_id in keys(local.translated_experimental.multi_runner_config) : + entry_id => "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.storage[0].account_id}:instance/" + } + runner_config_ttl_seconds = local.dynamodb_storage_provider.runner_state.runner_config_ttl_seconds + runner_state_ttl_seconds = local.dynamodb_storage_provider.runner_state.runner_state_ttl_seconds + global_records = local.dynamodb_global_records + entry_records = local.dynamodb_entry_records +} + +locals { + dynamodb_storage_capabilities = one(module.storage_aws_dynamodb[*].capabilities) + + storage_provider_capabilities = local.storage_provider_type == "aws_dynamodb" ? local.dynamodb_storage_capabilities : { + webhook = { + direct = { + environment_variables = tomap({}) + iam_policy_json = null + } + eventbridge = { + webhook = { + environment_variables = tomap({}) + iam_policy_json = null + } + dispatcher = { + environment_variables = tomap({}) + iam_policy_json = null + } + } + } + entries = { + for entry_id in keys(local.translated_experimental.multi_runner_config) : entry_id => { + scale_up = { + environment_variables = tomap({}) + iam_policy_json = null + } + scale_down = { + environment_variables = tomap({}) + iam_policy_json = null + } + pool = { + environment_variables = tomap({}) + iam_policy_json = null + } + job_retry = { + environment_variables = tomap({}) + iam_policy_json = null + } + runner = { + config_table_name = null + runner_state_table_name = null + scope = null + iam_policy_json = null + } + } + } + } +} diff --git a/modules/multi-runner/tests/provider-routing-v1.tftest.hcl b/modules/multi-runner/tests/provider-routing-v1.tftest.hcl index ebe79fb8aa..dab94e897a 100644 --- a/modules/multi-runner/tests/provider-routing-v1.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing-v1.tftest.hcl @@ -319,6 +319,7 @@ run "stable_v1_keeps_legacy_runner_module" { "github", "lambda", "orchestration_provider", + "storage_provider", "ssm", "observability", "compute_provider", @@ -425,6 +426,10 @@ run "stable_v1_keeps_legacy_runner_module" { && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider)) == toset(["aws"]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.aws)) == toset(["ec2"]) && toset(keys(local.raw_translated_experimental.multi_runner_config["linux"].compute_provider.aws.ec2.binaries_syncer)) == toset(["enabled"]) + && local.raw_translated_experimental.storage_provider.aws.dynamodb == null + && local.raw_translated_experimental.storage_provider.aws.ssm != null + && local.storage_provider_type == "aws_ssm" + && length(module.storage_aws_dynamodb) == 0 && !contains(keys(local.raw_translated_experimental.multi_runner_config["linux"].ssm), "kms_key_id") && !contains(keys(local.raw_translated_experimental.runner), "boot_time_in_minutes") && !contains(keys(local.raw_translated_experimental.runner), "ephemeral") diff --git a/modules/multi-runner/tests/provider-routing-v2.tftest.hcl b/modules/multi-runner/tests/provider-routing-v2.tftest.hcl index 531d32ee12..4c5745b25a 100644 --- a/modules/multi-runner/tests/provider-routing-v2.tftest.hcl +++ b/modules/multi-runner/tests/provider-routing-v2.tftest.hcl @@ -577,7 +577,9 @@ run "experimental_v2_routes_through_provider_stack" { assert { condition = ( - local.translated_experimental.multi_runner_config["linux"].ssm.paths.root == "/github-action-runners/github-actions/linux" + local.storage_provider_type == "aws_ssm" + && length(module.storage_aws_dynamodb) == 0 + && local.translated_experimental.multi_runner_config["linux"].ssm.paths.root == "/github-action-runners/github-actions/linux" && local.translated_experimental.multi_runner_config["linux"].ssm.paths.tokens == "runners/tokens" && local.translated_experimental.multi_runner_config["linux"].ssm.paths.config == "runners/config" && var.kms_key_arn == null @@ -4583,3 +4585,165 @@ run "experimental_v2_rejects_invalid_ssm_housekeeper_state" { expect_failures = [terraform_data.validate_experimental] } + +run "experimental_v2_wires_opt_in_dynamodb_storage" { + command = plan + + variables { + experimental = { + storage_provider = { + aws = { + dynamodb = { + runner_state = { + runner_config_ttl_seconds = 3600 + runner_state_ttl_seconds = 604800 + } + } + } + } + + github = { + app = { + id = "123456" + key_base64 = "dGVzdA==" + webhook_secret = "test-secret" + } + } + + ssm = { + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/ssm-only" + } + + orchestration_provider = { + webhook = { + runner = { + maximum_count = 2 + } + lambda = { + artifact = { + zip = "README.md" + } + webhook = { + artifact = { + zip = "README.md" + } + } + pool = { + config = [{ + schedule_expression = "cron(0 * * * ? *)" + size = 1 + }] + runner_owner = "octo-org" + } + } + } + } + + compute_provider = { + aws = { + ec2 = { + vpc_id = "vpc-dynamodb" + subnet_ids = ["subnet-dynamodb"] + runner_binaries = { + enabled = false + } + } + } + } + + multi_runner_config = { + windows = { + runner = { + os = "windows" + architecture = "x64" + } + orchestration_provider = { + webhook = { + runner = { + ephemeral = true + } + matcherConfig = { + labelMatchers = [["self-hosted", "windows", "x64", "dynamodb"]] + priority = 10 + } + job_retry = { + enabled = true + } + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + binaries_syncer = { + enabled = false + } + cloudwatch_agent = { + enabled = false + } + } + } + } + } + } + } + } + + assert { + condition = ( + local.storage_provider_type == "aws_dynamodb" + && local.translated_experimental.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/ssm-only" + && local.webhook_storage_kms_key_arn == null + && length(module.storage_aws_dynamodb) == 1 + && module.storage_aws_dynamodb[0].config_table.name == "github-actions-config" + && module.storage_aws_dynamodb[0].runner_state_table.name == "github-actions-runner-state" + && contains(keys(module.storage_aws_dynamodb[0].capabilities.entries["windows"].scale_up.environment_variables), "RUNNER_CONFIG_STORAGE_PROVIDER") + && contains(keys(module.storage_aws_dynamodb[0].capabilities.entries["windows"].scale_up.environment_variables), "RUNNER_CONFIG_STORAGE_VERSION") + && contains(keys(local.storage_provider_capabilities.entries["windows"].scale_up.environment_variables), "RUNNER_CONFIG_STORAGE_PROVIDER") + ) + error_message = "Explicit aws.dynamodb selection must create exactly one shared two-table storage-provider module." + } + + assert { + condition = ( + module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["RUNNER_CONFIG_STORAGE_PROVIDER"] == "aws_dynamodb" + && contains(keys(module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables), "RUNNER_CONFIG_STORAGE_VERSION") + && module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["RUNNER_CONFIG_DYNAMODB_ENTRY_ID"] == "windows" + && module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables["EC2_INSTANCE_ARN_PREFIX"] == "arn:aws:ec2:eu-west-1:123456789012:instance/" + && !contains(keys(module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables), "SSM_TOKEN_PATH") + && !contains(keys(module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables), "SSM_CONFIG_PATH") + && !contains(keys(module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables), "SSM_PARAMETER_STORE_TAGS") + && !contains(keys(module.runner_configs["windows"].orchestration_provider.webhook.scale_up.lambda.environment[0].variables), "PARAMETER_GITHUB_APP_ID_NAME") + && module.runner_configs["windows"].orchestration_provider.webhook.pool.lambda.environment[0].variables["EC2_INSTANCE_ARN_PREFIX"] == "arn:aws:ec2:eu-west-1:123456789012:instance/" + && !contains(keys(module.runner_configs["windows"].orchestration_provider.webhook.pool.lambda.environment[0].variables), "PARAMETER_GITHUB_APP_KEY_BASE64_NAME") + && !contains(keys(module.runner_configs["windows"].orchestration_provider.webhook.job_retry.lambda.function.environment[0].variables), "PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME") + ) + error_message = "DynamoDB control-plane Lambdas must receive the opaque provider and EC2 access-scope contract without legacy SSM environment variables." + } + + assert { + condition = ( + output.webhook.lambda.environment[0].variables["RUNNER_CONFIG_STORAGE_PROVIDER"] == "aws_dynamodb" + && contains(keys(output.webhook.lambda.environment[0].variables), "RUNNER_CONFIG_STORAGE_VERSION") + && !contains(keys(output.webhook.lambda.environment[0].variables), "PARAMETER_GITHUB_APP_WEBHOOK_SECRET") + && !contains(keys(output.webhook.lambda.environment[0].variables), "PARAMETER_RUNNER_MATCHER_CONFIG_PATH") + && !contains(keys(output.webhook.lambda.environment[0].variables), "RUNNER_MATCHER_CONFIG_VERSION") + && contains(keys(output.webhook.dispatcher.lambda.environment[0].variables), "RUNNER_CONFIG_STORAGE_VERSION") + && contains(keys(output.webhook.dispatcher.lambda.environment[0].variables), "RUNNER_MATCHER_CONFIG_VERSION") + && !contains(keys(output.webhook.dispatcher.lambda.environment[0].variables), "PARAMETER_RUNNER_MATCHER_CONFIG_PATH") + ) + error_message = "EventBridge ingress and dispatcher must receive isolated DynamoDB capabilities without SSM secret or matcher locators." + } + + assert { + condition = ( + strcontains(base64decode(module.runner_configs["windows"].provider.aws.ec2.launch_template.user_data), "aws dynamodb delete-item") + && strcontains(base64decode(module.runner_configs["windows"].provider.aws.ec2.launch_template.user_data), "attribute_exists(#expires_at) AND #expires_at > :now") + && strcontains(base64decode(module.runner_configs["windows"].provider.aws.ec2.launch_template.user_data), "--return-values ALL_OLD") + && strcontains(base64decode(module.runner_configs["windows"].provider.aws.ec2.launch_template.user_data), "compute-resource") + && strcontains(base64decode(module.runner_configs["windows"].provider.aws.ec2.launch_template.user_data), base64encode("arn:aws:ec2:eu-west-1:123456789012:instance/")) + && !strcontains(base64decode(module.runner_configs["windows"].provider.aws.ec2.launch_template.user_data), "aws ssm get-parameter --name") + ) + error_message = "DynamoDB-selected Windows runners must atomically consume only their unexpired compute-scoped configuration without embedding raw user-controlled locators." + } +} diff --git a/modules/multi-runner/variables.experimental.tf b/modules/multi-runner/variables.experimental.tf index ae19eab604..d6575789f8 100644 --- a/modules/multi-runner/variables.experimental.tf +++ b/modules/multi-runner/variables.experimental.tf @@ -71,6 +71,13 @@ variable "experimental" { - `lambda.tags`: Default tags for v2 runner-config functions and the shared webhook, runner-binary syncer, termination watcher, and AMI housekeeper. The default is `{}`. - `lambda.role.path`: IAM path for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.path`. - `lambda.role.permissions_boundary`: Permissions-boundary ARN for module-managed v2 Lambda roles and the shared webhook, runner-binary syncer, termination-watcher, and AMI-housekeeper roles. The default is null, which falls back to `roles.permissions_boundary`. + - `storage_provider`: Global runner-configuration storage selection. It applies to every v2 runner entry and cannot be overridden per entry. When omitted, the existing SSM provider remains selected. + - `storage_provider.aws.dynamodb`: Selects two shared DynamoDB tables for the whole multi-runner deployment: `-config` for durable global and entry configuration, and `-runner-state` for TTL-backed runner configuration and lifecycle records. + - `storage_provider.aws.dynamodb.config`: Durable table encryption, point-in-time recovery, deletion protection, and tags. + - `storage_provider.aws.dynamodb.runner_state`: Ephemeral table encryption, recovery, deletion protection, TTL durations, and tags. + - `storage_provider.aws.dynamodb.runner_state.runner_config_ttl_seconds`: Lifetime for one-time runner registration and JIT configuration records. The default is 86400 seconds. + - `storage_provider.aws.dynamodb.runner_state.runner_state_ttl_seconds`: Safety retention applied only to stale provisioning and terminating lifecycle records. Active and orphan inventory does not expire. The default is 604800 seconds. + - `storage_provider.aws.ssm`: Retains the existing Parameter Store storage backend for v2. Select this when GitHub App inputs reference externally managed `*_ssm` parameters. - `orchestration_provider.webhook`: Global defaults for the workflow-job webhook control plane. This is a defaults namespace; every runner configuration still selects its demand controller independently under `multi_runner_config[].orchestration_provider`, where exactly one typed provider block must be non-null. - `orchestration_provider.webhook.queue_selection_strategy`: Queue-selection strategy when multiple runner configurations match a job equally well. The default is `first`, which deterministically selects the first matching queue by priority. `random` spreads jobs across equally matched queues. `all` dispatches to every matching queue, favoring startup speed at the cost of multiple runner launches and registrations for one job. - `orchestration_provider.webhook.eventbridge.enable`: Routes accepted webhook events through EventBridge when true. The default is `true`, and the value must be known during planning because it selects the webhook implementation. @@ -546,6 +553,28 @@ variable "experimental" { }), {}) }), {}) + storage_provider = optional(object({ + aws = optional(object({ + dynamodb = optional(object({ + config = optional(object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, true) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }), {}) + runner_state = optional(object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, false) + deletion_protection_enabled = optional(bool, false) + runner_config_ttl_seconds = optional(number, 86400) + runner_state_ttl_seconds = optional(number, 604800) + tags = optional(map(string), {}) + }), {}) + }), null) + ssm = optional(object({}), null) + }), {}) + }), {}) + orchestration_provider = optional(object({ webhook = optional(object({ queue_selection_strategy = optional(string, "first") @@ -1170,4 +1199,63 @@ variable "experimental" { })), {}) }) default = {} + + validation { + condition = ( + (try(var.experimental.storage_provider.aws.dynamodb, null) != null ? 1 : 0) + + (try(var.experimental.storage_provider.aws.ssm, null) != null ? 1 : 0) + ) <= 1 + error_message = "experimental.storage_provider must select at most one typed provider: aws.dynamodb or aws.ssm. An omitted provider retains aws.ssm." + } + + validation { + condition = ( + length(var.experimental.multi_runner_config) == 0 || + try(var.experimental.storage_provider.aws.dynamodb, null) == null || + ( + try(var.experimental.github.app.id_ssm, null) == null && + try(var.experimental.github.app.key_base64_ssm, null) == null && + try(var.experimental.github.app.webhook_secret_ssm, null) == null && + alltrue([ + for app in var.experimental.github.additional_apps : + app.id_ssm == null && app.key_base64_ssm == null && app.installation_id_ssm == null + ]) + ) + ) + error_message = "experimental.storage_provider.aws.dynamodb requires direct GitHub App values; it will not copy externally managed *_ssm SecureString values into Terraform state. Omit storage_provider or select experimental.storage_provider.aws.ssm to retain external Parameter Store references." + } + + validation { + condition = ( + length(var.experimental.multi_runner_config) == 0 || + try(var.experimental.storage_provider.aws.dynamodb, null) == null || + ( + coalesce(try(tonumber(var.experimental.github.app.id), null), 0) > 0 && + floor(coalesce(try(tonumber(var.experimental.github.app.id), null), 0)) == coalesce(try(tonumber(var.experimental.github.app.id), null), 0) && + alltrue([ + for app in var.experimental.github.additional_apps : + coalesce(try(tonumber(app.id), null), 0) > 0 && + floor(coalesce(try(tonumber(app.id), null), 0)) == coalesce(try(tonumber(app.id), null), 0) && + ( + app.installation_id == null || + ( + coalesce(try(tonumber(app.installation_id), null), 0) > 0 && + floor(coalesce(try(tonumber(app.installation_id), null), 0)) == coalesce(try(tonumber(app.installation_id), null), 0) + ) + ) + ]) + ) + ) + error_message = "experimental.storage_provider.aws.dynamodb requires positive integer direct GitHub App IDs and installation IDs." + } + + validation { + condition = ( + try(var.experimental.storage_provider.aws.dynamodb.runner_state.runner_config_ttl_seconds, 86400) > 0 && + floor(try(var.experimental.storage_provider.aws.dynamodb.runner_state.runner_config_ttl_seconds, 86400)) == try(var.experimental.storage_provider.aws.dynamodb.runner_state.runner_config_ttl_seconds, 86400) && + try(var.experimental.storage_provider.aws.dynamodb.runner_state.runner_state_ttl_seconds, 604800) > try(var.experimental.storage_provider.aws.dynamodb.runner_state.runner_config_ttl_seconds, 86400) && + floor(try(var.experimental.storage_provider.aws.dynamodb.runner_state.runner_state_ttl_seconds, 604800)) == try(var.experimental.storage_provider.aws.dynamodb.runner_state.runner_state_ttl_seconds, 604800) + ) + error_message = "DynamoDB TTL values must be positive integers, and runner_state_ttl_seconds must be greater than runner_config_ttl_seconds." + } } diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index 51bc3b6089..908f6091e2 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -12,16 +12,21 @@ locals { matcherConfig = v.orchestration_provider.webhook.matcherConfig } } + + webhook_storage_kms_key_arn = local.storage_provider_type == "aws_ssm" ? local.translated_experimental.ssm.kms_key_id : null } module "webhook" { source = "../webhook" prefix = var.prefix tags = merge(local.translated_experimental.tags, { "ghr:environment" = var.prefix }) - kms_key_arn = local.translated_experimental.ssm.kms_key_id + kms_key_arn = local.webhook_storage_kms_key_arn eventbridge = local.translated_experimental.orchestration_provider.webhook.eventbridge runner_matcher_config = local.runner_matcher_config matcher_config_parameter_store_tier = local.translated_experimental.orchestration_provider.webhook.matcher_config_parameter_store_tier + storage_provider = merge(local.storage_provider_capabilities.webhook, { + type = local.storage_provider_type + }) ssm_paths = { root = trimsuffix(coalesce(local.translated_experimental.ssm.paths.root, "/github-action-runners/${var.prefix}"), "/") diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index 81c6def4d3..ba7301a25e 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -10,20 +10,20 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | | [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | @@ -31,13 +31,13 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
- `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 applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | | [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | @@ -47,12 +47,13 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale | [runner](#input\_runner) | Common runner registration values consumed by webhook demand controls. Lifecycle, boot timeout, and capacity remain provider-owned under config.runner. |
object({
os = string
auto_update_disabled = bool
labels = list(string)
group_name = string
name_prefix = string
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Selected compute-provider capabilities consumed by webhook scale-up, scale-down, and pool controls. |
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
})
pool = object({
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
})
| n/a | yes | | [ssm](#input\_ssm) | Resolved Parameter Store paths, optional decrypt key, and runtime parameter tags. |
object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
kms_key_id = optional(string, null)
parameter_store_tags = string
})
| n/a | yes | +| [storage\_provider](#input\_storage\_provider) | Opaque storage-provider environment and IAM capabilities for webhook control-plane functions. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
pool = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
job_retry = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
})
|
{
"job_retry": {
"environment_variables": {},
"iam_policy_json": null
},
"pool": {
"environment_variables": {},
"iam_policy_json": null
},
"scale_down": {
"environment_variables": {},
"iam_policy_json": null
},
"scale_up": {
"environment_variables": {},
"iam_policy_json": null
},
"type": "aws_ssm"
}
| no | | [tags](#input\_tags) | Base tags available to webhook-provider resources. Component-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [job\_retry](#output\_job\_retry) | Job-retry resources. Null when job retry is disabled. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool schedule is configured. | | [runner\_lifecycle](#output\_runner\_lifecycle) | Effective webhook-owned runner lifecycle consumed by runner-config bootstrap parameters. | diff --git a/modules/orchestration-providers/webhook/job-retry.tf b/modules/orchestration-providers/webhook/job-retry.tf index 651e12c9ea..fe05707ed1 100644 --- a/modules/orchestration-providers/webhook/job-retry.tf +++ b/modules/orchestration-providers/webhook/job-retry.tf @@ -45,4 +45,9 @@ module "job_retry" { event_source_mapping = local.job_retry_queue_tags } } + + storage_provider = merge( + { type = local.resolved_config.storage_provider.type }, + local.resolved_config.storage_provider.job_retry, + ) } diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md index 5a572d25eb..e0c36ff3b4 100644 --- a/modules/orchestration-providers/webhook/job-retry/README.md +++ b/modules/orchestration-providers/webhook/job-retry/README.md @@ -11,14 +11,14 @@ The module is an inner module used by the webhook orchestration provider when th ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | | [terraform](#provider\_terraform) | n/a | @@ -29,7 +29,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 | @@ -51,13 +51,14 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.

- `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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `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)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
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_id = 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
})
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 | +| [storage\_provider](#input\_storage\_provider) | Opaque runner-configuration storage capability used by the job-retry Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
|
{
"environment_variables": {},
"iam_policy_json": null,
"type": "aws_ssm"
}
| no | ## 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/orchestration-providers/webhook/job-retry/iam-policies.tf b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf index 0e79e8a265..68310119fc 100644 --- a/modules/orchestration-providers/webhook/job-retry/iam-policies.tf +++ b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf @@ -52,20 +52,26 @@ data "aws_iam_policy_document" "lambda_xray" { } data "aws_iam_policy_document" "job_retry" { - statement { - sid = "WebhookJobRetryReadGitHubAppParameters" - effect = "Allow" + source_policy_documents = compact([var.storage_provider.iam_policy_json]) - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] + + content { + sid = "WebhookJobRetryReadGitHubAppParameters" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] - resources = concat( - [for p in var.config.github.app_parameters.id : p.arn], - [for p in var.config.github.app_parameters.key_base64 : p.arn], - [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], - ) + resources = concat( + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], + ) + } } statement { @@ -94,7 +100,7 @@ data "aws_iam_policy_document" "job_retry" { } dynamic "statement" { - for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.ssm.kms_key_id != null ? [var.config.ssm.kms_key_id] : [] iterator = kms_key content { diff --git a/modules/orchestration-providers/webhook/job-retry/job-retry.tf b/modules/orchestration-providers/webhook/job-retry/job-retry.tf index 7d819786aa..d55af67cdc 100644 --- a/modules/orchestration-providers/webhook/job-retry/job-retry.tf +++ b/modules/orchestration-providers/webhook/job-retry/job-retry.tf @@ -19,23 +19,28 @@ locals { } job_retry_environment_variables = { - 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 - NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 - USER_AGENT = var.config.github.user_agent - JOB_QUEUE_SCALE_UP_URL = var.config.queue.build.url + 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 + NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 + USER_AGENT = var.config.github.user_agent + JOB_QUEUE_SCALE_UP_URL = var.config.queue.build.url + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + } + + ssm_environment_variables = { PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github.app_parameters.id : p.name]) PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.name : ""]) - RUNNER_NAME_PREFIX = var.config.runner.name_prefix } environment_variables = merge( local.lambda_environment_variables, var.config.lambda.environment_variables, local.job_retry_environment_variables, + var.storage_provider.type == "aws_ssm" ? local.ssm_environment_variables : {}, + var.storage_provider.environment_variables, ) } diff --git a/modules/orchestration-providers/webhook/job-retry/variables.tf b/modules/orchestration-providers/webhook/job-retry/variables.tf index b4df0402b5..349c04b4d2 100644 --- a/modules/orchestration-providers/webhook/job-retry/variables.tf +++ b/modules/orchestration-providers/webhook/job-retry/variables.tf @@ -141,3 +141,17 @@ variable "config" { nullable = false } + +variable "storage_provider" { + description = "Opaque runner-configuration storage capability used by the job-retry Lambda." + type = object({ + type = string + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + default = { + type = "aws_ssm" + environment_variables = {} + iam_policy_json = null + } +} diff --git a/modules/orchestration-providers/webhook/main.tf b/modules/orchestration-providers/webhook/main.tf index d9b1722d98..8dd566ec18 100644 --- a/modules/orchestration-providers/webhook/main.tf +++ b/modules/orchestration-providers/webhook/main.tf @@ -30,12 +30,13 @@ locals { queue = merge(var.config.queue, { event_source_mapping = var.config.lambda.scale.up.event_source_mapping }) - scale_up = var.config.lambda.scale.up - scale_down = var.config.lambda.scale.down - pool = var.config.lambda.pool - job_retry = var.config.job_retry - ssm = var.ssm - observability = var.observability + scale_up = var.config.lambda.scale.up + scale_down = var.config.lambda.scale.down + pool = var.config.lambda.pool + job_retry = var.config.job_retry + ssm = var.ssm + storage_provider = var.storage_provider + observability = var.observability } common_tags = local.resolved_config.tags diff --git a/modules/orchestration-providers/webhook/pool.tf b/modules/orchestration-providers/webhook/pool.tf index 6fb9ad3d34..4ed40fb919 100644 --- a/modules/orchestration-providers/webhook/pool.tf +++ b/modules/orchestration-providers/webhook/pool.tf @@ -56,6 +56,10 @@ module "pool" { aws_partition = var.aws_partition tracing_config = local.resolved_config.observability.tracing + storage_provider = merge( + { type = local.resolved_config.storage_provider.type }, + local.resolved_config.storage_provider.pool, + ) runner_provider = { type = var.runner_provider.type environment_variables = var.runner_provider.pool.environment_variables diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index 877eec8039..960259f5a9 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/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.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | | [terraform](#provider\_terraform) | n/a | @@ -27,7 +27,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 | @@ -52,15 +52,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) | Configuration passed from the webhook orchestration provider 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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
boot_time_in_minutes = number
})
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_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = 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 | +| [storage\_provider](#input\_storage\_provider) | Opaque runner-configuration storage capability used by the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
|
{
"environment_variables": {},
"iam_policy_json": null,
"type": "aws_ssm"
}
| no | | [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 | -|------|-------------| +| ---- | ----------- | | [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/orchestration-providers/webhook/pool/iam-policies.tf b/modules/orchestration-providers/webhook/pool/iam-policies.tf index f5a9285bce..334aa2845c 100644 --- a/modules/orchestration-providers/webhook/pool/iam-policies.tf +++ b/modules/orchestration-providers/webhook/pool/iam-policies.tf @@ -1,56 +1,68 @@ # IAM policies attached to the pool Lambda role. data "aws_iam_policy_document" "pool_common" { - statement { - sid = "WebhookPoolWriteRuntimeParameters" - effect = "Allow" + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - actions = [ - "ssm:AddTagsToResource", - "ssm:PutParameter", - ] + content { + sid = "WebhookPoolWriteRuntimeParameters" + effect = "Allow" - resources = [ - var.config.ssm_token_path_arn, - "${var.config.ssm_token_path_arn}/*", - var.config.arn_ssm_parameters_path_config, - "${var.config.arn_ssm_parameters_path_config}/*", - ] + actions = [ + "ssm:AddTagsToResource", + "ssm:PutParameter", + ] + + resources = [ + var.config.ssm_token_path_arn, + "${var.config.ssm_token_path_arn}/*", + var.config.arn_ssm_parameters_path_config, + "${var.config.arn_ssm_parameters_path_config}/*", + ] + } } - statement { - sid = "WebhookPoolReadRunnerConfigParameters" - effect = "Allow" + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath", - ] + content { + sid = "WebhookPoolReadRunnerConfigParameters" + effect = "Allow" - resources = [ - var.config.arn_ssm_parameters_path_config, - "${var.config.arn_ssm_parameters_path_config}/*", - ] + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + ] + + resources = [ + var.config.arn_ssm_parameters_path_config, + "${var.config.arn_ssm_parameters_path_config}/*", + ] + } } - statement { - sid = "WebhookPoolReadGitHubAppParameters" - effect = "Allow" + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] + content { + sid = "WebhookPoolReadGitHubAppParameters" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] - resources = concat( - [for p in var.config.github_app_parameters.id : p.arn], - [for p in var.config.github_app_parameters.key_base64 : p.arn], - [for p in var.config.github_app_parameters.installation_id : p.arn if p != null], - ) + resources = concat( + [for p in var.config.github_app_parameters.id : p.arn], + [for p in var.config.github_app_parameters.key_base64 : p.arn], + [for p in var.config.github_app_parameters.installation_id : p.arn if p != null], + ) + } } dynamic "statement" { - for_each = var.config.kms_key_id == null ? [] : [var.config.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.kms_key_id != null ? [var.config.kms_key_id] : [] iterator = kms_key content { diff --git a/modules/orchestration-providers/webhook/pool/pool.tf b/modules/orchestration-providers/webhook/pool/pool.tf index cff2776e90..ce3319c8c8 100644 --- a/modules/orchestration-providers/webhook/pool/pool.tf +++ b/modules/orchestration-providers/webhook/pool/pool.tf @@ -7,32 +7,35 @@ locals { ) 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 + 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 + 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 + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes + RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count + 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 + INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners + } + + ssm_environment_variables = { PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github_app_parameters.id : p.name]) PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github_app_parameters.key_base64 : p.name]) PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github_app_parameters.installation_id : p != null ? p.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 - RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes - 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 } } @@ -54,7 +57,12 @@ 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 = merge( + var.runner_provider.environment_variables, + local.common_environment_variables, + var.storage_provider.type == "aws_ssm" ? local.ssm_environment_variables : {}, + var.storage_provider.environment_variables, + ) } dynamic "vpc_config" { @@ -96,10 +104,11 @@ resource "aws_iam_role_policy" "pool" { } data "aws_iam_policy_document" "pool" { - source_policy_documents = [ + source_policy_documents = compact([ data.aws_iam_policy_document.pool_common.json, var.runner_provider.iam_policy_json, - ] + var.storage_provider.iam_policy_json, + ]) } resource "aws_iam_role_policy" "pool_logging" { diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf index e1f516c8ad..2b3b9cfb0b 100644 --- a/modules/orchestration-providers/webhook/pool/variables.tf +++ b/modules/orchestration-providers/webhook/pool/variables.tf @@ -138,6 +138,20 @@ variable "runner_provider" { }) } +variable "storage_provider" { + description = "Opaque runner-configuration storage capability used by the pool Lambda." + type = object({ + type = string + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + default = { + type = "aws_ssm" + environment_variables = {} + iam_policy_json = null + } +} + variable "aws_partition" { description = "(optional) partition for the arn if not 'aws'" type = string diff --git a/modules/orchestration-providers/webhook/scale-runners.tf b/modules/orchestration-providers/webhook/scale-runners.tf index caf79eefb5..cdcbc9350a 100644 --- a/modules/orchestration-providers/webhook/scale-runners.tf +++ b/modules/orchestration-providers/webhook/scale-runners.tf @@ -53,6 +53,12 @@ module "scale_runners" { } } + storage_provider = { + type = local.resolved_config.storage_provider.type + scale_up = local.resolved_config.storage_provider.scale_up + scale_down = local.resolved_config.storage_provider.scale_down + } + runner_provider = { type = var.runner_provider.type scale_up = var.runner_provider.scale_up diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index ba9ca9be9d..f60c5f0660 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -10,14 +10,14 @@ The module is an implementation detail of the experimental runner configuration. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | @@ -28,7 +28,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 | @@ -65,15 +65,16 @@ 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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume 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.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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
boot_time_in_minutes = number
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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = 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
})
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 | +| [storage\_provider](#input\_storage\_provider) | Opaque storage-provider capabilities for scale-up and scale-down. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
})
|
{
"scale_down": {
"environment_variables": {},
"iam_policy_json": null
},
"scale_up": {
"environment_variables": {},
"iam_policy_json": null
},
"type": "aws_ssm"
}
| no | ## 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/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf index b95cb9e686..79571c2747 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf @@ -1,20 +1,24 @@ data "aws_iam_policy_document" "scale_down_common" { - statement { - sid = "WebhookScaleDownReadGitHubAppParameters" - effect = "Allow" - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] - resources = concat( - [for p in var.config.github.app_parameters.id : p.arn], - [for p in var.config.github.app_parameters.key_base64 : p.arn], - [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], - ) + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] + + content { + sid = "WebhookScaleDownReadGitHubAppParameters" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = concat( + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], + ) + } } dynamic "statement" { - for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.ssm.kms_key_id != null ? [var.config.ssm.kms_key_id] : [] iterator = kms_key content { @@ -27,10 +31,11 @@ data "aws_iam_policy_document" "scale_down_common" { } data "aws_iam_policy_document" "scale_down" { - source_policy_documents = [ + source_policy_documents = compact([ data.aws_iam_policy_document.scale_down_common.json, var.runner_provider.scale_down.iam_policy_json, - ] + var.storage_provider.scale_down.iam_policy_json, + ]) } data "aws_iam_policy_document" "scale_down_logging" { diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf index 4951363bc9..f0ad9cbf35 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf @@ -15,26 +15,27 @@ resource "aws_lambda_function" "scale_down" { 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 + 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 + 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 + COMPUTE_PROVIDER_TYPE = var.runner_provider.type + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes + }, var.storage_provider.type == "aws_ssm" ? { PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github.app_parameters.id : p.name]) PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.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 - COMPUTE_PROVIDER_TYPE = var.runner_provider.type - RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes - }) + } : {}, var.storage_provider.scale_down.environment_variables) } dynamic "vpc_config" { diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf index b3c87b8ad7..3fddefbe99 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf @@ -1,35 +1,43 @@ data "aws_iam_policy_document" "scale_up_common" { - statement { - sid = "WebhookScaleUpWriteRuntimeParameters" - effect = "Allow" - actions = [ - "ssm:PutParameter", - "ssm:AddTagsToResource", - ] - resources = [ - var.config.ssm.token_path_arn, - "${var.config.ssm.token_path_arn}/*", - var.config.ssm.config_path_arn, - "${var.config.ssm.config_path_arn}/*", - ] - } + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - statement { - sid = "WebhookScaleUpReadGitHubAppAndRunnerConfigParameters" - effect = "Allow" - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] - resources = concat( - [for p in var.config.github.app_parameters.id : p.arn], - [for p in var.config.github.app_parameters.key_base64 : p.arn], - [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], - [ + content { + sid = "WebhookScaleUpWriteRuntimeParameters" + effect = "Allow" + actions = [ + "ssm:PutParameter", + "ssm:AddTagsToResource", + ] + resources = [ + var.config.ssm.token_path_arn, + "${var.config.ssm.token_path_arn}/*", var.config.ssm.config_path_arn, "${var.config.ssm.config_path_arn}/*", - ], - ) + ] + } + } + + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] + + content { + sid = "WebhookScaleUpReadGitHubAppAndRunnerConfigParameters" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = concat( + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], + [ + var.config.ssm.config_path_arn, + "${var.config.ssm.config_path_arn}/*", + ], + ) + } } statement { @@ -44,7 +52,7 @@ data "aws_iam_policy_document" "scale_up_common" { } dynamic "statement" { - for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.ssm.kms_key_id != null ? [var.config.ssm.kms_key_id] : [] iterator = kms_key content { @@ -69,10 +77,11 @@ data "aws_iam_policy_document" "scale_up_common" { } data "aws_iam_policy_document" "scale_up" { - source_policy_documents = [ + source_policy_documents = compact([ data.aws_iam_policy_document.scale_up_common.json, var.runner_provider.scale_up.iam_policy_json, - ] + var.storage_provider.scale_up.iam_policy_json, + ]) } data "aws_iam_policy_document" "scale_up_logging" { diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf index 92dfa4267a..51eea16406 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf @@ -16,37 +16,38 @@ resource "aws_lambda_function" "scale_up" { 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 + 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 + 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 + COMPUTE_PROVIDER_TYPE = var.runner_provider.type + RUNNERS_MAXIMUM_COUNT = var.config.runner.maximum_count + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-up" + JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) + }, var.storage_provider.type == "aws_ssm" ? { PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github.app_parameters.id : p.name]) PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.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 - COMPUTE_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) - }) + } : {}, var.storage_provider.scale_up.environment_variables) } dynamic "vpc_config" { diff --git a/modules/orchestration-providers/webhook/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf index 3910d7ea66..4857387b03 100644 --- a/modules/orchestration-providers/webhook/scale-runners/variables.tf +++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf @@ -229,3 +229,29 @@ variable "runner_provider" { nullable = false } + +variable "storage_provider" { + description = "Opaque storage-provider capabilities for scale-up and scale-down." + type = object({ + type = string + scale_up = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + scale_down = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }) + default = { + type = "aws_ssm" + scale_up = { + environment_variables = {} + iam_policy_json = null + } + scale_down = { + environment_variables = {} + iam_policy_json = null + } + } +} diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf index 1ecc898ef4..6da4840b1a 100644 --- a/modules/orchestration-providers/webhook/variables.tf +++ b/modules/orchestration-providers/webhook/variables.tf @@ -215,6 +215,48 @@ variable "ssm" { }) } +variable "storage_provider" { + description = "Opaque storage-provider environment and IAM capabilities for webhook control-plane functions." + type = object({ + type = string + scale_up = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + scale_down = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + pool = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + job_retry = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }) + default = { + type = "aws_ssm" + scale_up = { + environment_variables = {} + iam_policy_json = null + } + scale_down = { + environment_variables = {} + iam_policy_json = null + } + pool = { + environment_variables = {} + iam_policy_json = null + } + job_retry = { + environment_variables = {} + iam_policy_json = null + } + } +} + variable "observability" { description = "Common logging, tracing, and metrics configuration consumed by webhook controls." type = object({ diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index cff1ce73c7..a445cda801 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -69,21 +69,21 @@ yarn run dist ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.33 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [compute\_aws\_ec2](#module\_compute\_aws\_ec2) | ../compute-providers/aws/ec2 | n/a | | [compute\_aws\_ec2\_trust\_policy](#module\_compute\_aws\_ec2\_trust\_policy) | ../compute-providers/aws/ec2/trust-policy | n/a | | [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | @@ -92,7 +92,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 | @@ -106,7 +106,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.

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

- `paths.root`: Root Parameter Store path for this runner configuration.
- `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_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `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.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `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_id = optional(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({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
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 | +| [storage\_provider](#input\_storage\_provider) | Selected runner-configuration storage provider and its opaque capability contracts.

The runner-config module forwards Lambda environment and IAM additions to the orchestration provider and the runner bootstrap locator/IAM policy to the selected compute provider. Consumers do not inspect provider-specific environment keys or IAM statements. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
pool = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
job_retry = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
runner = object({
config_table_name = optional(string, null)
runner_state_table_name = optional(string, null)
scope = optional(string, null)
iam_policy_json = optional(string, null)
})
})
|
{
"job_retry": {
"environment_variables": {},
"iam_policy_json": null
},
"pool": {
"environment_variables": {},
"iam_policy_json": null
},
"runner": {
"config_table_name": null,
"iam_policy_json": null,
"runner_state_table_name": null,
"scope": null
},
"scale_down": {
"environment_variables": {},
"iam_policy_json": null
},
"scale_up": {
"environment_variables": {},
"iam_policy_json": null
},
"type": "aws_ssm"
}
| no | | [tags](#input\_tags) | Base tags added to taggable resources created by this runner configuration. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes. | `map(string)` | `{}` | no | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. | diff --git a/modules/runner-config/compute-provider.aws.ec2.tf b/modules/runner-config/compute-provider.aws.ec2.tf index 5d053a73dc..cf83728faf 100644 --- a/modules/runner-config/compute-provider.aws.ec2.tf +++ b/modules/runner-config/compute-provider.aws.ec2.tf @@ -21,8 +21,12 @@ module "compute_aws_ec2" { managed_policy_arns = local.common_runner_managed_policy_arns }) }) - github = var.github - ssm = var.ssm + github = var.github + ssm = var.ssm + storage_provider = { + type = var.storage_provider.type + runner = var.storage_provider.runner + } observability = var.observability } diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf index 25994beaba..6294f17046 100644 --- a/modules/runner-config/orchestration-provider.tf +++ b/modules/runner-config/orchestration-provider.tf @@ -48,6 +48,13 @@ module "orchestration_webhook" { parameter_store_tags = local.parameter_store_tags } observability = var.observability + storage_provider = { + type = var.storage_provider.type + scale_up = var.storage_provider.scale_up + scale_down = var.storage_provider.scale_down + pool = var.storage_provider.pool + job_retry = var.storage_provider.job_retry + } runner_provider = { type = local.provider_type diff --git a/modules/runner-config/runner-ssm-parameters.tf b/modules/runner-config/runner-ssm-parameters.tf index 1d97c908a8..cceebe0845 100644 --- a/modules/runner-config/runner-ssm-parameters.tf +++ b/modules/runner-config/runner-ssm-parameters.tf @@ -1,5 +1,6 @@ # Shared runner configuration stored in SSM Parameter Store. resource "aws_ssm_parameter" "runner_agent_mode" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/agent_mode" type = "String" value = local.orchestration_provider_runner_lifecycle.ephemeral ? "ephemeral" : "persistent" @@ -7,6 +8,7 @@ resource "aws_ssm_parameter" "runner_agent_mode" { } resource "aws_ssm_parameter" "disable_default_labels" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/disable_default_labels" type = "String" value = var.runner.disable_default_labels @@ -14,6 +16,7 @@ resource "aws_ssm_parameter" "disable_default_labels" { } resource "aws_ssm_parameter" "jit_config_enabled" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_jit_config" type = "String" value = local.orchestration_provider_runner_lifecycle.jit_config_enabled @@ -21,8 +24,29 @@ resource "aws_ssm_parameter" "jit_config_enabled" { } resource "aws_ssm_parameter" "token_path" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/token_path" type = "String" value = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" tags = local.ssm_parameter_tags } + +moved { + from = aws_ssm_parameter.runner_agent_mode + to = aws_ssm_parameter.runner_agent_mode[0] +} + +moved { + from = aws_ssm_parameter.disable_default_labels + to = aws_ssm_parameter.disable_default_labels[0] +} + +moved { + from = aws_ssm_parameter.jit_config_enabled + to = aws_ssm_parameter.jit_config_enabled[0] +} + +moved { + from = aws_ssm_parameter.token_path + to = aws_ssm_parameter.token_path[0] +} diff --git a/modules/runner-config/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper.tf index 5bb31bb7b5..5cabffa0ec 100644 --- a/modules/runner-config/ssm-housekeeper.tf +++ b/modules/runner-config/ssm-housekeeper.tf @@ -7,6 +7,7 @@ locals { module "ssm_housekeeper" { source = "./ssm-housekeeper" + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 config = { prefix = var.prefix @@ -55,3 +56,8 @@ module "ssm_housekeeper" { } } } + +moved { + from = module.ssm_housekeeper + to = module.ssm_housekeeper[0] +} diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl index 12a81854c3..3bf45a4a17 100644 --- a/modules/runner-config/tests/pool.tftest.hcl +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -158,8 +158,8 @@ run "plan_with_pool_enabled" { assert { condition = ( - aws_ssm_parameter.runner_agent_mode.value == "ephemeral" - && aws_ssm_parameter.jit_config_enabled.value == "true" + aws_ssm_parameter.runner_agent_mode[0].value == "ephemeral" + && aws_ssm_parameter.jit_config_enabled[0].value == "true" ) error_message = "Runner-config must serialize the webhook provider's resolved lifecycle contract without duplicating its JIT fallback." } diff --git a/modules/runner-config/tests/tags.tftest.hcl b/modules/runner-config/tests/tags.tftest.hcl index 6c004879ba..5d603fa63d 100644 --- a/modules/runner-config/tests/tags.tftest.hcl +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -238,7 +238,7 @@ run "layered_component_tags" { } assert { - condition = aws_ssm_parameter.runner_agent_mode.tags == tomap({ + condition = aws_ssm_parameter.runner_agent_mode[0].tags == tomap({ precedence = "ssm-parameter" module = "yes" ssm = "yes" diff --git a/modules/runner-config/variables.storage-provider.tf b/modules/runner-config/variables.storage-provider.tf new file mode 100644 index 0000000000..317f32b1ae --- /dev/null +++ b/modules/runner-config/variables.storage-provider.tf @@ -0,0 +1,57 @@ +variable "storage_provider" { + description = <<-EOT + Selected runner-configuration storage provider and its opaque capability contracts. + + The runner-config module forwards Lambda environment and IAM additions to the orchestration provider and the runner bootstrap locator/IAM policy to the selected compute provider. Consumers do not inspect provider-specific environment keys or IAM statements. + EOT + type = object({ + type = string + scale_up = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + scale_down = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + pool = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + job_retry = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + runner = object({ + config_table_name = optional(string, null) + runner_state_table_name = optional(string, null) + scope = optional(string, null) + iam_policy_json = optional(string, null) + }) + }) + default = { + type = "aws_ssm" + scale_up = { + environment_variables = {} + iam_policy_json = null + } + scale_down = { + environment_variables = {} + iam_policy_json = null + } + pool = { + environment_variables = {} + iam_policy_json = null + } + job_retry = { + environment_variables = {} + iam_policy_json = null + } + runner = { + config_table_name = null + runner_state_table_name = null + scope = null + iam_policy_json = null + } + } +} diff --git a/modules/storage-providers/aws/dynamodb/README.md b/modules/storage-providers/aws/dynamodb/README.md new file mode 100644 index 0000000000..d79186c8bb --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/README.md @@ -0,0 +1,57 @@ +# DynamoDB runner-config storage provider + +This internal module creates the two shared DynamoDB tables used by an opt-in multi-runner v2 deployment: one durable configuration table and one TTL-enabled runner-state table. It stores global and per-entry configuration under capability-specific partition-key scopes and returns opaque Lambda and runner capabilities with matching least-privilege IAM policies. + +The durable table isolates GitHub App credentials, webhook secrets, matcher configuration, runner-group cache entries, and bootstrap records by `scope`. The runner-state table keeps lifecycle inventory under entry-specific scopes and one-time registration configuration under the compute resource's access scope. For EC2, `compute-resource` means the full source-instance ARN; the runner can atomically read and delete only its own unexpired record. + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_dynamodb_table.config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource | +| [aws_dynamodb_table.runner_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource | +| [aws_dynamodb_table_item.github_app_credentials](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.github_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.runner_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.runner_matcher_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Settings for the shared durable configuration table and ephemeral runner-state table.

- `config.kms_key_arn`: Optional customer-managed KMS key ARN for durable configuration encryption. Null uses the AWS-owned DynamoDB key.
- `config.point_in_time_recovery_enabled`: Enables point-in-time recovery for durable configuration.
- `config.deletion_protection_enabled`: Enables deletion protection for the durable table.
- `config.tags`: Tags applied after the shared tag map.
- `runner_state.kms_key_arn`: Optional customer-managed KMS key ARN for runner-state encryption. Null uses the AWS-owned DynamoDB key.
- `runner_state.point_in_time_recovery_enabled`: Enables point-in-time recovery for ephemeral runner state.
- `runner_state.deletion_protection_enabled`: Enables deletion protection for the runner-state table.
- `runner_state.tags`: Tags applied after the shared tag map. |
object({
config = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, true)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
runner_state = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, false)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
})
| n/a | yes | +| [entry\_ids](#input\_entry\_ids) | Runner-entry identifiers used to build entry-scoped Lambda capabilities. | `set(string)` | n/a | yes | +| [entry\_records](#input\_entry\_records) | Resolved durable runner bootstrap configuration keyed by runner-entry identifier. |
map(object({
run_as = string
agent_mode = string
disable_default_labels = bool
enable_jit_config = bool
}))
| n/a | yes | +| [global\_records](#input\_global\_records) | Terraform-managed values stored under the shared global scope. |
object({
github_app_credentials = string
github_webhook_secret = string
runner_matcher_config = string
})
| n/a | yes | +| [prefix](#input\_prefix) | Multi-runner prefix used to name the two shared DynamoDB tables. | `string` | n/a | yes | +| [runner\_config\_access\_scope\_prefixes](#input\_runner\_config\_access\_scope\_prefixes) | Per-entry compute-resource scope prefixes used to constrain one-time runner-config writes. | `map(string)` | n/a | yes | +| [runner\_config\_ttl\_seconds](#input\_runner\_config\_ttl\_seconds) | TTL in seconds for one-time registration and JIT configuration records. | `number` | n/a | yes | +| [runner\_state\_ttl\_seconds](#input\_runner\_state\_ttl\_seconds) | Safety TTL in seconds applied only while lifecycle records are provisioning or terminating; active and orphan inventory has no expiry. | `number` | n/a | yes | +| [tags](#input\_tags) | Base tags added to both shared DynamoDB tables. Table-specific tags override matching keys. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [capabilities](#output\_capabilities) | Opaque environment and least-privilege IAM additions consumed by the shared webhook and each runner entry's control-plane functions. | +| [config\_table](#output\_config\_table) | Shared durable configuration table. Global and runner-entry records are separated by the `scope` partition key. | +| [runner\_state\_table](#output\_runner\_state\_table) | Shared TTL-backed table containing ephemeral runner configuration and provider-neutral runner lifecycle records. | + diff --git a/modules/storage-providers/aws/dynamodb/capabilities.tf b/modules/storage-providers/aws/dynamodb/capabilities.tf new file mode 100644 index 0000000000..ad25a59c6a --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/capabilities.tf @@ -0,0 +1,243 @@ +locals { + config_environment_variables = { + RUNNER_CONFIG_STORAGE_PROVIDER = "aws_dynamodb" + RUNNER_CONFIG_STORAGE_VERSION = terraform_data.config_version.id + RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = aws_dynamodb_table.config.name + } + + matcher_environment_variables = merge(local.config_environment_variables, { + RUNNER_MATCHER_CONFIG_VERSION = nonsensitive(sha256(var.global_records.runner_matcher_config)) + }) + + runner_state_environment_variables = { + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME = aws_dynamodb_table.runner_state.name + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS = tostring(var.runner_state_ttl_seconds) + } + + runner_config_environment_variables = { + RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = tostring(var.runner_config_ttl_seconds) + } + + global_scopes = { + github_app = "global#github-app" + webhook = "global#webhook" + matcher = "global#matcher" + } + + entry_scopes = { + for entry_id in var.entry_ids : entry_id => { + bootstrap = "entry#${entry_id}#bootstrap" + runner_group = "entry#${entry_id}#runner-group" + runner_state = "entry#${entry_id}#runner-state" + } + } + + entry_environment_variables = { + for entry_id, scopes in local.entry_scopes : entry_id => merge(local.config_environment_variables, { + RUNNER_CONFIG_DYNAMODB_ENTRY_ID = entry_id + }) + } + + scale_up_environment_variables = { + for entry_id in var.entry_ids : entry_id => merge( + local.entry_environment_variables[entry_id], + local.runner_state_environment_variables, + local.runner_config_environment_variables, + ) + } + + scale_down_environment_variables = { + for entry_id in var.entry_ids : entry_id => merge( + local.entry_environment_variables[entry_id], + local.runner_state_environment_variables, + ) + } + + github_app_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.github_app] + } + } + } + + direct_webhook_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.webhook, local.global_scopes.matcher] + } + } + } + + eventbridge_webhook_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.webhook] + } + } + } + + dispatcher_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.matcher] + } + } + } + + direct_webhook_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [local.direct_webhook_read_statement] + }) + + eventbridge_webhook_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [local.eventbridge_webhook_read_statement] + }) + + dispatcher_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [local.dispatcher_read_statement] + }) + + entry_runner_group_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = ["dynamodb:GetItem", "dynamodb:PutItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_group] + } + } + } + } + + runner_config_write_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = ["dynamodb:PutItem"] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringLike" = { + "dynamodb:LeadingKeys" = ["${var.runner_config_access_scope_prefixes[entry_id]}*"] + } + } + } + } + + runner_state_write_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = [ + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:UpdateItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_state] + } + } + } + } + + runner_state_reconcile_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = [ + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:UpdateItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_state] + } + } + } + } + + scale_up_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [ + local.github_app_read_statement, + local.entry_runner_group_statements[entry_id], + local.runner_config_write_statements[entry_id], + local.runner_state_write_statements[entry_id], + ] + }) + } + + scale_down_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [local.github_app_read_statement, local.runner_state_reconcile_statements[entry_id]] + }) + } + + pool_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [ + local.github_app_read_statement, + local.entry_runner_group_statements[entry_id], + local.runner_config_write_statements[entry_id], + local.runner_state_write_statements[entry_id], + ] + }) + } + + job_retry_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [local.github_app_read_statement] + }) + } + + runner_iam_policy_json = { + for entry_id, scopes in local.entry_scopes : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.bootstrap] + } + } + }, + { + Effect = "Allow" + Action = [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = ["$${ec2:SourceInstanceARN}"] + } + } + }, + ] + }) + } +} diff --git a/modules/storage-providers/aws/dynamodb/config-version.tf b/modules/storage-providers/aws/dynamodb/config-version.tf new file mode 100644 index 0000000000..3e8e1fe611 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/config-version.tf @@ -0,0 +1,6 @@ +resource "terraform_data" "config_version" { + triggers_replace = sensitive({ + global_records = sha256(jsonencode(var.global_records)) + entry_records = sha256(jsonencode(var.entry_records)) + }) +} diff --git a/modules/storage-providers/aws/dynamodb/items.tf b/modules/storage-providers/aws/dynamodb/items.tf new file mode 100644 index 0000000000..bc34f70e19 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/items.tf @@ -0,0 +1,56 @@ +resource "aws_dynamodb_table_item" "github_app_credentials" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.github_app } + id = { S = "github-app-credentials" } + value = { S = var.global_records.github_app_credentials } + }) +} + +resource "aws_dynamodb_table_item" "github_webhook_secret" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.webhook } + id = { S = "github-webhook-secret" } + value = { S = var.global_records.github_webhook_secret } + }) +} + +resource "aws_dynamodb_table_item" "runner_matcher_config" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.matcher } + id = { S = "runner-matcher-config" } + value = { S = var.global_records.runner_matcher_config } + }) +} + +resource "aws_dynamodb_table_item" "runner_config" { + for_each = var.entry_records + + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.entry_scopes[each.key].bootstrap } + id = { S = "runner-config" } + value = { S = jsonencode(merge(each.value, { + runner_config_storage = { + provider = "aws_dynamodb" + table_name = aws_dynamodb_table.runner_state.name + access_scope = "compute-resource" + id = "config" + } + })) } + }) +} diff --git a/modules/storage-providers/aws/dynamodb/outputs.tf b/modules/storage-providers/aws/dynamodb/outputs.tf new file mode 100644 index 0000000000..4c337b212d --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/outputs.tf @@ -0,0 +1,71 @@ +output "config_table" { + description = "Shared durable configuration table. Global and runner-entry records are separated by the `scope` partition key." + value = { + arn = aws_dynamodb_table.config.arn + name = aws_dynamodb_table.config.name + } +} + +output "runner_state_table" { + description = "Shared TTL-backed table containing ephemeral runner configuration and provider-neutral runner lifecycle records." + value = { + arn = aws_dynamodb_table.runner_state.arn + name = aws_dynamodb_table.runner_state.name + ttl_attribute_name = "expires_at" + } +} + +output "capabilities" { + description = "Opaque environment and least-privilege IAM additions consumed by the shared webhook and each runner entry's control-plane functions." + depends_on = [ + aws_dynamodb_table_item.github_app_credentials, + aws_dynamodb_table_item.github_webhook_secret, + aws_dynamodb_table_item.runner_matcher_config, + aws_dynamodb_table_item.runner_config, + terraform_data.config_version, + ] + value = { + webhook = { + direct = { + environment_variables = tomap(local.matcher_environment_variables) + iam_policy_json = local.direct_webhook_iam_policy_json + } + eventbridge = { + webhook = { + environment_variables = tomap(local.config_environment_variables) + iam_policy_json = local.eventbridge_webhook_iam_policy_json + } + dispatcher = { + environment_variables = tomap(local.matcher_environment_variables) + iam_policy_json = local.dispatcher_iam_policy_json + } + } + } + entries = { + for entry_id in var.entry_ids : entry_id => { + scale_up = { + environment_variables = tomap(local.scale_up_environment_variables[entry_id]) + iam_policy_json = local.scale_up_iam_policy_json[entry_id] + } + scale_down = { + environment_variables = tomap(local.scale_down_environment_variables[entry_id]) + iam_policy_json = local.scale_down_iam_policy_json[entry_id] + } + pool = { + environment_variables = tomap(local.scale_up_environment_variables[entry_id]) + iam_policy_json = local.pool_iam_policy_json[entry_id] + } + job_retry = { + environment_variables = tomap(local.config_environment_variables) + iam_policy_json = local.job_retry_iam_policy_json[entry_id] + } + runner = { + config_table_name = aws_dynamodb_table.config.name + runner_state_table_name = aws_dynamodb_table.runner_state.name + scope = local.entry_scopes[entry_id].bootstrap + iam_policy_json = local.runner_iam_policy_json[entry_id] + } + } + } + } +} diff --git a/modules/storage-providers/aws/dynamodb/tables.tf b/modules/storage-providers/aws/dynamodb/tables.tf new file mode 100644 index 0000000000..12cd94867c --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/tables.tf @@ -0,0 +1,62 @@ +resource "aws_dynamodb_table" "config" { + name = "${var.prefix}-config" + billing_mode = "PAY_PER_REQUEST" + hash_key = "scope" + range_key = "id" + + attribute { + name = "scope" + type = "S" + } + + attribute { + name = "id" + type = "S" + } + + point_in_time_recovery { + enabled = var.config.config.point_in_time_recovery_enabled + } + + server_side_encryption { + enabled = true + kms_key_arn = var.config.config.kms_key_arn + } + + deletion_protection_enabled = var.config.config.deletion_protection_enabled + tags = merge(var.tags, var.config.config.tags) +} + +resource "aws_dynamodb_table" "runner_state" { + name = "${var.prefix}-runner-state" + billing_mode = "PAY_PER_REQUEST" + hash_key = "scope" + range_key = "id" + + attribute { + name = "scope" + type = "S" + } + + attribute { + name = "id" + type = "S" + } + + ttl { + attribute_name = "expires_at" + enabled = true + } + + point_in_time_recovery { + enabled = var.config.runner_state.point_in_time_recovery_enabled + } + + server_side_encryption { + enabled = true + kms_key_arn = var.config.runner_state.kms_key_arn + } + + deletion_protection_enabled = var.config.runner_state.deletion_protection_enabled + tags = merge(var.tags, var.config.runner_state.tags) +} diff --git a/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl b/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl new file mode 100644 index 0000000000..e2afb15ae1 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl @@ -0,0 +1,245 @@ +mock_provider "aws" { + mock_resource "aws_dynamodb_table" { + defaults = { + arn = "arn:aws:dynamodb:eu-west-1:123456789012:table/test" + } + } +} + +variables { + prefix = "github-actions" + entry_ids = ["linux", "microvm"] + runner_config_access_scope_prefixes = { + linux = "arn:aws:ec2:eu-west-1:123456789012:instance/" + microvm = "arn:aws:ec2:eu-west-1:123456789012:instance/" + } + runner_config_ttl_seconds = 3600 + runner_state_ttl_seconds = 604800 + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "test-secret" + runner_matcher_config = jsonencode([{ key = "linux" }]) + } + entry_records = { + linux = { + run_as = "runner" + agent_mode = "ephemeral" + disable_default_labels = false + enable_jit_config = true + } + microvm = { + run_as = "root" + agent_mode = "ephemeral" + disable_default_labels = true + enable_jit_config = true + } + } + tags = { + Environment = "test" + Shared = "base" + } + config = { + config = { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/config" + point_in_time_recovery_enabled = true + deletion_protection_enabled = true + tags = { + Shared = "config" + } + } + runner_state = { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/runner-state" + point_in_time_recovery_enabled = false + deletion_protection_enabled = false + tags = { + Shared = "runner-state" + } + } + } +} + +run "creates_two_shared_scoped_tables" { + command = apply + + assert { + condition = ( + aws_dynamodb_table.config.name == "github-actions-config" + && aws_dynamodb_table.config.billing_mode == "PAY_PER_REQUEST" + && aws_dynamodb_table.config.hash_key == "scope" + && aws_dynamodb_table.config.range_key == "id" + && aws_dynamodb_table.config.point_in_time_recovery[0].enabled + && aws_dynamodb_table.config.deletion_protection_enabled + && aws_dynamodb_table.config.server_side_encryption[0].kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/config" + && aws_dynamodb_table.config.tags["Shared"] == "config" + ) + error_message = "The durable provider table must be one encrypted, scoped, on-demand table for the whole multi-runner deployment." + } + + assert { + condition = ( + aws_dynamodb_table.runner_state.name == "github-actions-runner-state" + && aws_dynamodb_table.runner_state.billing_mode == "PAY_PER_REQUEST" + && aws_dynamodb_table.runner_state.hash_key == "scope" + && aws_dynamodb_table.runner_state.range_key == "id" + && aws_dynamodb_table.runner_state.ttl[0].enabled + && aws_dynamodb_table.runner_state.ttl[0].attribute_name == "expires_at" + && !aws_dynamodb_table.runner_state.point_in_time_recovery[0].enabled + && !aws_dynamodb_table.runner_state.deletion_protection_enabled + && aws_dynamodb_table.runner_state.server_side_encryption[0].kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/runner-state" + && aws_dynamodb_table.runner_state.tags["Shared"] == "runner-state" + ) + error_message = "The runner-state provider table must be one encrypted, TTL-backed, scoped, on-demand table for the whole multi-runner deployment." + } + + assert { + condition = ( + output.config_table.name == "github-actions-config" + && output.runner_state_table.name == "github-actions-runner-state" + && output.runner_state_table.ttl_attribute_name == "expires_at" + ) + error_message = "The provider outputs must expose the two shared table contracts." + } + + assert { + condition = ( + output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_PROVIDER"] == "aws_dynamodb" + && output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME"] == "github-actions-config" + && output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.eventbridge.webhook.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.eventbridge.dispatcher.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.direct.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] == sha256(jsonencode([{ key = "linux" }])) + && !contains(keys(output.capabilities.webhook.direct.environment_variables), "RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME") + && !contains(keys(output.capabilities.webhook.direct.environment_variables), "RUNNER_CONFIG_DYNAMODB_TTL_SECONDS") + && !contains(keys(output.capabilities.webhook.eventbridge.webhook.environment_variables), "RUNNER_MATCHER_CONFIG_VERSION") + && output.capabilities.webhook.eventbridge.dispatcher.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] == sha256(jsonencode([{ key = "linux" }])) + && output.capabilities.entries["linux"].scale_up.environment_variables["RUNNER_CONFIG_DYNAMODB_ENTRY_ID"] == "linux" + && output.capabilities.entries["linux"].scale_up.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && !contains(keys(output.capabilities.entries["linux"].scale_up.environment_variables), "RUNNER_MATCHER_CONFIG_VERSION") + && output.capabilities.entries["microvm"].scale_down.environment_variables["RUNNER_CONFIG_DYNAMODB_ENTRY_ID"] == "microvm" + && output.capabilities.entries["microvm"].scale_down.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.entries["linux"].pool.environment_variables["RUNNER_CONFIG_DYNAMODB_TTL_SECONDS"] == "3600" + && output.capabilities.entries["linux"].scale_down.environment_variables["RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS"] == "604800" + && !contains(keys(output.capabilities.entries["linux"].scale_down.environment_variables), "RUNNER_CONFIG_DYNAMODB_TTL_SECONDS") + && !contains(keys(output.capabilities.entries["linux"].job_retry.environment_variables), "RUNNER_CONFIG_DYNAMODB_ENTRY_ID") + && !contains(keys(output.capabilities.entries["linux"].job_retry.environment_variables), "RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME") + && output.capabilities.entries["linux"].job_retry.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.entries["linux"].runner.config_table_name == "github-actions-config" + && output.capabilities.entries["linux"].runner.runner_state_table_name == "github-actions-runner-state" + && output.capabilities.entries["linux"].runner.scope == "entry#linux#bootstrap" + ) + error_message = "The provider must expose one global and entry-scoped Lambda environment contract over the same two tables." + } + + assert { + condition = ( + jsondecode(output.capabilities.webhook.direct.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#webhook", "global#matcher"] + && jsondecode(output.capabilities.webhook.eventbridge.webhook.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#webhook"] + && jsondecode(output.capabilities.webhook.eventbridge.dispatcher.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#matcher"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[0].Action == ["dynamodb:GetItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#github-app"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[1].Action == ["dynamodb:GetItem", "dynamodb:PutItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[1].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#linux#runner-group"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[2].Action == ["dynamodb:PutItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[2].Condition["ForAllValues:StringLike"]["dynamodb:LeadingKeys"] == ["arn:aws:ec2:eu-west-1:123456789012:instance/*"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[3].Action == ["dynamodb:PutItem", "dynamodb:Query", "dynamodb:UpdateItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[3].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#linux#runner-state"] + && jsondecode(output.capabilities.entries["microvm"].scale_down.iam_policy_json).Statement[1].Action == ["dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:UpdateItem"] + && jsondecode(output.capabilities.entries["microvm"].scale_down.iam_policy_json).Statement[1].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#microvm#runner-state"] + && !contains(jsondecode(output.capabilities.entries["linux"].pool.iam_policy_json).Statement[3].Action, "dynamodb:DeleteItem") + && jsondecode(output.capabilities.entries["linux"].runner.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#linux#bootstrap"] + && jsondecode(output.capabilities.entries["linux"].runner.iam_policy_json).Statement[1].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["$${ec2:SourceInstanceARN}"] + ) + error_message = "Provider IAM capabilities must restrict global and entry operations with DynamoDB leading-key conditions." + } + + + assert { + condition = ( + jsondecode(aws_dynamodb_table_item.github_app_credentials.item).scope.S == "global#github-app" + && jsondecode(aws_dynamodb_table_item.github_webhook_secret.item).scope.S == "global#webhook" + && jsondecode(aws_dynamodb_table_item.runner_matcher_config.item).scope.S == "global#matcher" + && jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).scope.S == "entry#linux#bootstrap" + && jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).id.S == "runner-config" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).run_as == "runner" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).runner_config_storage.table_name == "github-actions-runner-state" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).runner_config_storage.access_scope == "compute-resource" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).runner_config_storage.id == "config" + ) + error_message = "Each entry must receive one durable bootstrap record that points at its scope in the shared runner-state table." + } +} + +run "storage_version_tracks_global_record_changes" { + command = apply + + variables { + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "rotated-test-secret" + runner_matcher_config = jsonencode([{ key = "linux" }]) + } + } + + assert { + condition = ( + output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.entries["linux"].job_retry.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + ) + error_message = "A durable global-record update must publish the replacement storage resource ID to every Lambda capability." + } +} + +run "matcher_version_tracks_matcher_content" { + command = apply + + variables { + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "rotated-test-secret" + runner_matcher_config = jsonencode([{ key = "microvm" }]) + } + } + + assert { + condition = ( + output.capabilities.webhook.direct.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] == sha256(jsonencode([{ key = "microvm" }])) + && output.capabilities.webhook.direct.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] != sha256(jsonencode([{ key = "linux" }])) + && output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + ) + error_message = "The matcher and opaque storage versions must change without exposing the matcher payload whenever the durable matcher record changes." + } +} + +run "storage_version_tracks_entry_record_changes" { + command = apply + + variables { + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "rotated-test-secret" + runner_matcher_config = jsonencode([{ key = "microvm" }]) + } + entry_records = { + linux = { + run_as = "root" + agent_mode = "ephemeral" + disable_default_labels = false + enable_jit_config = true + } + microvm = { + run_as = "root" + agent_mode = "ephemeral" + disable_default_labels = true + enable_jit_config = true + } + } + } + + assert { + condition = ( + output.capabilities.entries["linux"].scale_up.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.eventbridge.webhook.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + ) + error_message = "A durable entry-record update must publish the replacement storage resource ID to every Lambda capability." + } +} diff --git a/modules/storage-providers/aws/dynamodb/variables.tf b/modules/storage-providers/aws/dynamodb/variables.tf new file mode 100644 index 0000000000..eeb490e61c --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/variables.tf @@ -0,0 +1,99 @@ +variable "prefix" { + description = "Multi-runner prefix used to name the two shared DynamoDB tables." + type = string +} + +variable "tags" { + description = "Base tags added to both shared DynamoDB tables. Table-specific tags override matching keys." + type = map(string) + default = {} +} + +variable "entry_ids" { + description = "Runner-entry identifiers used to build entry-scoped Lambda capabilities." + type = set(string) +} + +variable "runner_config_access_scope_prefixes" { + description = "Per-entry compute-resource scope prefixes used to constrain one-time runner-config writes." + type = map(string) + + validation { + condition = toset(keys(var.runner_config_access_scope_prefixes)) == var.entry_ids && alltrue([for prefix in values(var.runner_config_access_scope_prefixes) : trimspace(prefix) != ""]) + error_message = "runner_config_access_scope_prefixes must contain one non-empty prefix for every entry_id." + } +} + +variable "runner_config_ttl_seconds" { + description = "TTL in seconds for one-time registration and JIT configuration records." + type = number + + validation { + condition = var.runner_config_ttl_seconds > 0 && floor(var.runner_config_ttl_seconds) == var.runner_config_ttl_seconds + error_message = "runner_config_ttl_seconds must be a positive integer." + } +} + +variable "runner_state_ttl_seconds" { + description = "Safety TTL in seconds applied only while lifecycle records are provisioning or terminating; active and orphan inventory has no expiry." + type = number + + validation { + condition = var.runner_state_ttl_seconds > var.runner_config_ttl_seconds && floor(var.runner_state_ttl_seconds) == var.runner_state_ttl_seconds + error_message = "runner_state_ttl_seconds must be an integer greater than runner_config_ttl_seconds." + } +} + +variable "global_records" { + description = "Terraform-managed values stored under the shared global scope." + type = object({ + github_app_credentials = string + github_webhook_secret = string + runner_matcher_config = string + }) + sensitive = true +} + +variable "entry_records" { + description = "Resolved durable runner bootstrap configuration keyed by runner-entry identifier." + type = map(object({ + run_as = string + agent_mode = string + disable_default_labels = bool + enable_jit_config = bool + })) + + validation { + condition = toset(keys(var.entry_records)) == var.entry_ids + error_message = "entry_records must contain exactly one durable bootstrap record for every entry_id." + } +} + +variable "config" { + description = <<-EOT + Settings for the shared durable configuration table and ephemeral runner-state table. + + - `config.kms_key_arn`: Optional customer-managed KMS key ARN for durable configuration encryption. Null uses the AWS-owned DynamoDB key. + - `config.point_in_time_recovery_enabled`: Enables point-in-time recovery for durable configuration. + - `config.deletion_protection_enabled`: Enables deletion protection for the durable table. + - `config.tags`: Tags applied after the shared tag map. + - `runner_state.kms_key_arn`: Optional customer-managed KMS key ARN for runner-state encryption. Null uses the AWS-owned DynamoDB key. + - `runner_state.point_in_time_recovery_enabled`: Enables point-in-time recovery for ephemeral runner state. + - `runner_state.deletion_protection_enabled`: Enables deletion protection for the runner-state table. + - `runner_state.tags`: Tags applied after the shared tag map. + EOT + type = object({ + config = object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, true) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }) + runner_state = object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, false) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }) + }) +} diff --git a/modules/storage-providers/aws/dynamodb/versions.tf b/modules/storage-providers/aws/dynamodb/versions.tf new file mode 100644 index 0000000000..3ef011ea0a --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/webhook/README.md b/modules/webhook/README.md index 458ff5a7aa..b9e9195cee 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -34,7 +34,7 @@ yarn run dist ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3 | @@ -42,20 +42,20 @@ yarn run dist ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.21 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [direct](#module\_direct) | ./direct | n/a | | [eventbridge](#module\_eventbridge) | ./eventbridge | n/a | ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_apigatewayv2_api.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_api) | resource | | [aws_apigatewayv2_integration.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_integration) | resource | | [aws_apigatewayv2_route.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_route) | resource | @@ -65,7 +65,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 | | [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.

`enable`: Enable the EventBridge feature.
`accept_events`: List can be used to only allow specific events to be putted on the EventBridge. By default all events, empty list will be be interpreted as all events. |
object({
enable = optional(bool, false)
accept_events = optional(list(string), null)
})
| n/a | yes | | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
webhook_secret = map(string)
})
| n/a | yes | @@ -91,6 +91,7 @@ yarn run dist | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | | [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
| n/a | yes | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
webhook = string
})
| n/a | yes | +| [storage\_provider](#input\_storage\_provider) | Selected storage-provider type and opaque capabilities used by the webhook and optional dispatcher Lambdas. |
object({
type = optional(string, "aws_ssm")
direct = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
eventbridge = object({
webhook = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
dispatcher = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
})
})
|
{
"direct": {
"environment_variables": {},
"iam_policy_json": null
},
"eventbridge": {
"dispatcher": {
"environment_variables": {},
"iam_policy_json": null
},
"webhook": {
"environment_variables": {},
"iam_policy_json": null
}
},
"type": "aws_ssm"
}
| no | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | | [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | | [webhook\_lambda\_apigateway\_access\_log\_settings](#input\_webhook\_lambda\_apigateway\_access\_log\_settings) | Access log settings for webhook API gateway. |
object({
destination_arn = string
format = string
})
| `null` | no | @@ -100,7 +101,7 @@ yarn run dist ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [dispatcher](#output\_dispatcher) | n/a | | [endpoint\_relative\_path](#output\_endpoint\_relative\_path) | n/a | | [eventbridge](#output\_eventbridge) | n/a | diff --git a/modules/webhook/direct/README.md b/modules/webhook/direct/README.md index d639ed6398..d604bb3a97 100644 --- a/modules/webhook/direct/README.md +++ b/modules/webhook/direct/README.md @@ -2,7 +2,7 @@ ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3.2 | @@ -10,7 +10,7 @@ ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | | [null](#provider\_null) | ~> 3.2 | @@ -21,7 +21,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_log_group.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.webhook_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.webhook_kms](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -39,13 +39,13 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
storage_provider = optional(object({
type = optional(string, "aws_ssm")
environment_variables = map(string)
iam_policy_json = optional(string, null)
}), {
type = "aws_ssm"
environment_variables = {}
iam_policy_json = null
})
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [webhook](#output\_webhook) | n/a | | [webhook\_lambda\_function](#output\_webhook\_lambda\_function) | n/a | \ No newline at end of file diff --git a/modules/webhook/direct/variables.tf b/modules/webhook/direct/variables.tf index 402ac514b4..1729f97b23 100644 --- a/modules/webhook/direct/variables.tf +++ b/modules/webhook/direct/variables.tf @@ -48,5 +48,14 @@ variable "config" { arn = string version = string })) + storage_provider = optional(object({ + type = optional(string, "aws_ssm") + environment_variables = map(string) + iam_policy_json = optional(string, null) + }), { + type = "aws_ssm" + environment_variables = {} + iam_policy_json = null + }) }) } diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf index dd5548acf6..dd0fed1625 100644 --- a/modules/webhook/direct/webhook.tf +++ b/modules/webhook/direct/webhook.tf @@ -17,20 +17,20 @@ resource "aws_lambda_function" "webhook" { architectures = [var.config.lambda_architecture] environment { - variables = { + variables = merge({ for k, v in { LOG_LEVEL = upper(var.config.log_level) POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" POWERTOOLS_TRACE_ENABLED = var.config.tracing_config.mode != null ? true : false POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error - PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name + PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.storage_provider.type == "aws_ssm" ? var.config.github_app_parameters.webhook_secret.name : null REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list) QUEUE_SELECTION_STRATEGY = var.config.queue_selection_strategy - PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) - PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) : null + PARAMETER_RUNNER_MATCHER_VERSION = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) : null # enforce cold start after Changes in SSM parameter } : k => v if v != null - } + }, var.config.storage_provider.environment_variables) } dynamic "vpc_config" { @@ -123,6 +123,8 @@ resource "aws_iam_role_policy" "webhook_sqs" { } resource "aws_iam_role_policy" "webhook_kms" { + count = var.config.storage_provider.type == "aws_ssm" ? 1 : 0 + name = "kms-policy" role = aws_iam_role.webhook_lambda.name @@ -131,18 +133,23 @@ resource "aws_iam_role_policy" "webhook_kms" { }) } +moved { + from = aws_iam_role_policy.webhook_kms + to = aws_iam_role_policy.webhook_kms[0] +} + resource "aws_iam_role_policy" "webhook_ssm" { name = "publish-ssm-policy" role = aws_iam_role.webhook_lambda.name - policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + policy = var.config.storage_provider.type == "aws_ssm" ? templatefile("${path.module}/../policies/lambda-ssm.json", { resource_arns = jsonencode( concat( [var.config.github_app_parameters.webhook_secret.arn], [for p in var.config.ssm_parameter_runner_matcher_config : p.arn] ) ) - }) + }) : var.config.storage_provider.iam_policy_json } resource "aws_iam_role_policy" "xray" { diff --git a/modules/webhook/eventbridge/README.md b/modules/webhook/eventbridge/README.md index 07aa0bdd61..60281b09b3 100644 --- a/modules/webhook/eventbridge/README.md +++ b/modules/webhook/eventbridge/README.md @@ -2,7 +2,7 @@ ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3.2 | @@ -10,7 +10,7 @@ ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | >= 6.21 | | [null](#provider\_null) | ~> 3.2 | @@ -21,7 +21,7 @@ No modules. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_event_archive.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_archive) | resource | | [aws_cloudwatch_event_bus.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_bus) | resource | | [aws_cloudwatch_event_rule.workflow_job](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | @@ -53,13 +53,13 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
accept_events = optional(list(string), null)
})
| n/a | yes | +| ---- | ----------- | ---- | ------- | :------: | +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
storage_provider = optional(object({
type = optional(string, "aws_ssm")
webhook = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
dispatcher = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
}), {
type = "aws_ssm"
webhook = {
environment_variables = {}
iam_policy_json = null
}
dispatcher = {
environment_variables = {}
iam_policy_json = null
}
})
accept_events = optional(list(string), null)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [dispatcher](#output\_dispatcher) | n/a | | [eventbridge](#output\_eventbridge) | n/a | | [webhook](#output\_webhook) | n/a | diff --git a/modules/webhook/eventbridge/dispatcher.tf b/modules/webhook/eventbridge/dispatcher.tf index 2a0cc7577f..6daddafae4 100644 --- a/modules/webhook/eventbridge/dispatcher.tf +++ b/modules/webhook/eventbridge/dispatcher.tf @@ -35,7 +35,7 @@ resource "aws_lambda_function" "dispatcher" { architectures = [var.config.lambda_architecture] environment { - variables = { + variables = merge({ for k, v in { LOG_LEVEL = upper(var.config.log_level) POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" @@ -44,12 +44,12 @@ resource "aws_lambda_function" "dispatcher" { POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error # Parameters required for lambda configuration - PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) - PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) : null + PARAMETER_RUNNER_MATCHER_VERSION = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) : null # enforce cold start after Changes in SSM parameter REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list) QUEUE_SELECTION_STRATEGY = var.config.queue_selection_strategy } : k => v if v != null - } + }, var.config.storage_provider.dispatcher.environment_variables) } dynamic "vpc_config" { @@ -118,6 +118,8 @@ resource "aws_iam_role_policy" "dispatcher_sqs" { } resource "aws_iam_role_policy" "dispatcher_kms" { + count = var.config.storage_provider.type == "aws_ssm" ? 1 : 0 + name = "kms-policy" role = aws_iam_role.dispatcher_lambda.name @@ -126,17 +128,22 @@ resource "aws_iam_role_policy" "dispatcher_kms" { }) } +moved { + from = aws_iam_role_policy.dispatcher_kms + to = aws_iam_role_policy.dispatcher_kms[0] +} + resource "aws_iam_role_policy" "dispatcher_ssm" { name = "publish-ssm-policy" role = aws_iam_role.dispatcher_lambda.name - policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + policy = var.config.storage_provider.type == "aws_ssm" ? templatefile("${path.module}/../policies/lambda-ssm.json", { resource_arns = jsonencode( concat( [for p in var.config.ssm_parameter_runner_matcher_config : p.arn] ) ) - }) + }) : var.config.storage_provider.dispatcher.iam_policy_json } resource "aws_iam_role_policy" "dispatcher_xray" { diff --git a/modules/webhook/eventbridge/variables.tf b/modules/webhook/eventbridge/variables.tf index c6d35d82d3..91bbd39482 100644 --- a/modules/webhook/eventbridge/variables.tf +++ b/modules/webhook/eventbridge/variables.tf @@ -48,6 +48,27 @@ variable "config" { arn = string version = string })) + storage_provider = optional(object({ + type = optional(string, "aws_ssm") + webhook = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + dispatcher = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }), { + type = "aws_ssm" + webhook = { + environment_variables = {} + iam_policy_json = null + } + dispatcher = { + environment_variables = {} + iam_policy_json = null + } + }) accept_events = optional(list(string), null) }) } diff --git a/modules/webhook/eventbridge/webhook.tf b/modules/webhook/eventbridge/webhook.tf index 65e6b11e8e..1e5dc426bd 100644 --- a/modules/webhook/eventbridge/webhook.tf +++ b/modules/webhook/eventbridge/webhook.tf @@ -19,7 +19,7 @@ resource "aws_lambda_function" "webhook" { architectures = [var.config.lambda_architecture] environment { - variables = { + variables = merge({ for k, v in { LOG_LEVEL = upper(var.config.log_level) POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" @@ -30,10 +30,10 @@ resource "aws_lambda_function" "webhook" { # Parameters required for lambda configuration ACCEPT_EVENTS = jsonencode(var.config.accept_events) EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name - PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name - PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) + PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.storage_provider.type == "aws_ssm" ? var.config.github_app_parameters.webhook_secret.name : null + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) : null } : k => v if v != null - } + }, var.config.storage_provider.webhook.environment_variables) } dynamic "vpc_config" { @@ -124,12 +124,14 @@ resource "aws_iam_role_policy" "webhook_ssm" { name = "publish-ssm-policy" role = aws_iam_role.webhook_lambda.name - policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + policy = var.config.storage_provider.type == "aws_ssm" ? templatefile("${path.module}/../policies/lambda-ssm.json", { resource_arns = jsonencode([var.config.github_app_parameters.webhook_secret.arn]) - }) + }) : var.config.storage_provider.webhook.iam_policy_json } resource "aws_iam_role_policy" "webhook_kms" { + count = var.config.storage_provider.type == "aws_ssm" ? 1 : 0 + name = "kms-policy" role = aws_iam_role.webhook_lambda.name @@ -138,6 +140,11 @@ resource "aws_iam_role_policy" "webhook_kms" { }) } +moved { + from = aws_iam_role_policy.webhook_kms + to = aws_iam_role_policy.webhook_kms[0] +} + resource "aws_iam_role_policy" "xray" { count = var.config.tracing_config.mode != null ? 1 : 0 name = "xray-policy" diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index a2fae87e4a..0e464953fe 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -234,6 +234,49 @@ variable "matcher_config_parameter_store_tier" { } } +variable "storage_provider" { + description = "Selected storage-provider type and opaque capabilities used by the webhook and optional dispatcher Lambdas." + type = object({ + type = optional(string, "aws_ssm") + direct = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + eventbridge = object({ + webhook = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + dispatcher = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }) + }) + default = { + type = "aws_ssm" + direct = { + environment_variables = {} + iam_policy_json = null + } + eventbridge = { + webhook = { + environment_variables = {} + iam_policy_json = null + } + dispatcher = { + environment_variables = {} + iam_policy_json = null + } + } + } + + validation { + condition = contains(["aws_ssm", "aws_dynamodb"], var.storage_provider.type) + error_message = "storage_provider.type must be aws_ssm or aws_dynamodb." + } +} + variable "eventbridge" { description = < Date: Wed, 19 Aug 2026 13:03:16 +0000 Subject: [PATCH 48/49] docs: auto update terraform docs --- modules/compute-providers/aws/ec2/README.md | 12 ++++++------ modules/multi-runner/README.md | 16 ++++++++-------- .../orchestration-providers/webhook/README.md | 12 ++++++------ .../webhook/job-retry/README.md | 10 +++++----- .../webhook/pool/README.md | 10 +++++----- .../webhook/scale-runners/README.md | 10 +++++----- modules/runner-config/README.md | 14 +++++++------- modules/storage-providers/aws/dynamodb/README.md | 14 ++++++++------ modules/webhook/README.md | 14 +++++++------- modules/webhook/direct/README.md | 10 +++++----- modules/webhook/eventbridge/README.md | 10 +++++----- 11 files changed, 67 insertions(+), 65 deletions(-) diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md index b568b9419b..f4b5f42b45 100644 --- a/modules/compute-providers/aws/ec2/README.md +++ b/modules/compute-providers/aws/ec2/README.md @@ -10,15 +10,15 @@ EC2 is the only active compute provider. The parent runner configuration selects ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | ## Modules @@ -28,7 +28,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 | @@ -58,7 +58,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 by compute-provider resources and policy documents. | `string` | n/a | yes | | [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `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 | @@ -73,7 +73,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. | | [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. | | [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-config. | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 455541024f..6217ad4020 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -159,7 +159,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -167,15 +167,15 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | | [terraform](#provider\_terraform) | n/a | ## 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 | @@ -188,7 +188,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 | @@ -201,7 +201,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [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 | @@ -286,7 +286,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/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index ba7301a25e..0c938175ca 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -10,20 +10,20 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [job\_retry](#module\_job\_retry) | ./job-retry | n/a | | [pool](#module\_pool) | ./pool | n/a | | [scale\_runners](#module\_scale\_runners) | ./scale-runners | n/a | @@ -31,13 +31,13 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
- `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 applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
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 = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | | [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | @@ -53,7 +53,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [job\_retry](#output\_job\_retry) | Job-retry resources. Null when job retry is disabled. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool schedule is configured. | | [runner\_lifecycle](#output\_runner\_lifecycle) | Effective webhook-owned runner lifecycle consumed by runner-config bootstrap parameters. | diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md index e0c36ff3b4..178f3bc01d 100644 --- a/modules/orchestration-providers/webhook/job-retry/README.md +++ b/modules/orchestration-providers/webhook/job-retry/README.md @@ -11,14 +11,14 @@ The module is an inner module used by the webhook orchestration provider when th ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | | [terraform](#provider\_terraform) | n/a | @@ -29,7 +29,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 | @@ -51,14 +51,14 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.

- `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.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `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)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
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_id = 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
})
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 | | [storage\_provider](#input\_storage\_provider) | Opaque runner-configuration storage capability used by the job-retry Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
|
{
"environment_variables": {},
"iam_policy_json": null,
"type": "aws_ssm"
}
| no | ## 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/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index 960259f5a9..8491aae222 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/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.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | | [terraform](#provider\_terraform) | n/a | @@ -27,7 +27,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 | @@ -52,7 +52,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 webhook orchestration provider 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.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `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`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `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.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `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_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `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
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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
boot_time_in_minutes = number
})
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_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = 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 | @@ -62,6 +62,6 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [pool](#output\_pool) | Scheduled pool Lambda resources. | diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index f60c5f0660..a5f9d80d17 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -10,14 +10,14 @@ The module is an implementation detail of the experimental runner configuration. ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | @@ -28,7 +28,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 | @@ -65,7 +65,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-config.

- `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.
- `lambda.role.principals`: Additional principals allowed to assume 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.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `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`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `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.token_path_arn`: ARN of the 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_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `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)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
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
boot_time_in_minutes = number
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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = 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
})
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 | @@ -74,7 +74,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-config/README.md b/modules/runner-config/README.md index a445cda801..871e92acd8 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -69,21 +69,21 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | | [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [compute\_aws\_ec2](#module\_compute\_aws\_ec2) | ../compute-providers/aws/ec2 | n/a | | [compute\_aws\_ec2\_trust\_policy](#module\_compute\_aws\_ec2\_trust\_policy) | ../compute-providers/aws/ec2/trust-policy | n/a | | [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a | @@ -92,7 +92,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 | @@ -106,7 +106,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.

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

- `config.kms_key_arn`: Optional customer-managed KMS key ARN for durable configuration encryption. Null uses the AWS-owned DynamoDB key.
- `config.point_in_time_recovery_enabled`: Enables point-in-time recovery for durable configuration.
- `config.deletion_protection_enabled`: Enables deletion protection for the durable table.
- `config.tags`: Tags applied after the shared tag map.
- `runner_state.kms_key_arn`: Optional customer-managed KMS key ARN for runner-state encryption. Null uses the AWS-owned DynamoDB key.
- `runner_state.point_in_time_recovery_enabled`: Enables point-in-time recovery for ephemeral runner state.
- `runner_state.deletion_protection_enabled`: Enables deletion protection for the runner-state table.
- `runner_state.tags`: Tags applied after the shared tag map. |
object({
config = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, true)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
runner_state = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, false)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
})
| n/a | yes | | [entry\_ids](#input\_entry\_ids) | Runner-entry identifiers used to build entry-scoped Lambda capabilities. | `set(string)` | n/a | yes | | [entry\_records](#input\_entry\_records) | Resolved durable runner bootstrap configuration keyed by runner-entry identifier. |
map(object({
run_as = string
agent_mode = string
disable_default_labels = bool
enable_jit_config = bool
}))
| n/a | yes | @@ -50,7 +52,7 @@ No modules. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [capabilities](#output\_capabilities) | Opaque environment and least-privilege IAM additions consumed by the shared webhook and each runner entry's control-plane functions. | | [config\_table](#output\_config\_table) | Shared durable configuration table. Global and runner-entry records are separated by the `scope` partition key. | | [runner\_state\_table](#output\_runner\_state\_table) | Shared TTL-backed table containing ephemeral runner configuration and provider-neutral runner lifecycle records. | diff --git a/modules/webhook/README.md b/modules/webhook/README.md index b9e9195cee..f3e8de709b 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -34,7 +34,7 @@ yarn run dist ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3 | @@ -42,20 +42,20 @@ yarn run dist ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.21 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [direct](#module\_direct) | ./direct | n/a | | [eventbridge](#module\_eventbridge) | ./eventbridge | n/a | ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_apigatewayv2_api.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_api) | resource | | [aws_apigatewayv2_integration.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_integration) | resource | | [aws_apigatewayv2_route.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_route) | resource | @@ -65,7 +65,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 | | [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.

`enable`: Enable the EventBridge feature.
`accept_events`: List can be used to only allow specific events to be putted on the EventBridge. By default all events, empty list will be be interpreted as all events. |
object({
enable = optional(bool, false)
accept_events = optional(list(string), null)
})
| n/a | yes | | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
webhook_secret = map(string)
})
| n/a | yes | @@ -101,7 +101,7 @@ yarn run dist ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [dispatcher](#output\_dispatcher) | n/a | | [endpoint\_relative\_path](#output\_endpoint\_relative\_path) | n/a | | [eventbridge](#output\_eventbridge) | n/a | diff --git a/modules/webhook/direct/README.md b/modules/webhook/direct/README.md index d604bb3a97..0dd69652aa 100644 --- a/modules/webhook/direct/README.md +++ b/modules/webhook/direct/README.md @@ -2,7 +2,7 @@ ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3.2 | @@ -10,7 +10,7 @@ ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | | [null](#provider\_null) | ~> 3.2 | @@ -21,7 +21,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_role.webhook_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.webhook_kms](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -39,13 +39,13 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
storage_provider = optional(object({
type = optional(string, "aws_ssm")
environment_variables = map(string)
iam_policy_json = optional(string, null)
}), {
type = "aws_ssm"
environment_variables = {}
iam_policy_json = null
})
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [webhook](#output\_webhook) | n/a | | [webhook\_lambda\_function](#output\_webhook\_lambda\_function) | n/a | \ No newline at end of file diff --git a/modules/webhook/eventbridge/README.md b/modules/webhook/eventbridge/README.md index 60281b09b3..0cf90dda3f 100644 --- a/modules/webhook/eventbridge/README.md +++ b/modules/webhook/eventbridge/README.md @@ -2,7 +2,7 @@ ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3.2 | @@ -10,7 +10,7 @@ ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | >= 6.21 | | [null](#provider\_null) | ~> 3.2 | @@ -21,7 +21,7 @@ No modules. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_event_archive.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_archive) | resource | | [aws_cloudwatch_event_bus.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_bus) | resource | | [aws_cloudwatch_event_rule.workflow_job](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | @@ -53,13 +53,13 @@ No modules. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
storage_provider = optional(object({
type = optional(string, "aws_ssm")
webhook = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
dispatcher = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
}), {
type = "aws_ssm"
webhook = {
environment_variables = {}
iam_policy_json = null
}
dispatcher = {
environment_variables = {}
iam_policy_json = null
}
})
accept_events = optional(list(string), null)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [dispatcher](#output\_dispatcher) | n/a | | [eventbridge](#output\_eventbridge) | n/a | | [webhook](#output\_webhook) | n/a | From 0563c7c3ac131fd81e004e88f1bcf2eb51fe2980 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 15:12:39 +0200 Subject: [PATCH 49/49] fix(terraform): support Terraform 1.5 validation --- .../aws/dynamodb/capabilities.tf | 2 +- .../aws/dynamodb/config-version.tf | 17 ++++++++ .../aws/dynamodb/tests/provider.tftest.hcl | 39 +++++++++++++++++++ .../aws/dynamodb/variables.tf | 15 ------- 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/modules/storage-providers/aws/dynamodb/capabilities.tf b/modules/storage-providers/aws/dynamodb/capabilities.tf index ad25a59c6a..b5f47d36ea 100644 --- a/modules/storage-providers/aws/dynamodb/capabilities.tf +++ b/modules/storage-providers/aws/dynamodb/capabilities.tf @@ -132,7 +132,7 @@ locals { Resource = [aws_dynamodb_table.runner_state.arn] Condition = { "ForAllValues:StringLike" = { - "dynamodb:LeadingKeys" = ["${var.runner_config_access_scope_prefixes[entry_id]}*"] + "dynamodb:LeadingKeys" = ["${lookup(var.runner_config_access_scope_prefixes, entry_id, "__missing_runner_config_access_scope__")}*"] } } } diff --git a/modules/storage-providers/aws/dynamodb/config-version.tf b/modules/storage-providers/aws/dynamodb/config-version.tf index 3e8e1fe611..a0bd45605c 100644 --- a/modules/storage-providers/aws/dynamodb/config-version.tf +++ b/modules/storage-providers/aws/dynamodb/config-version.tf @@ -3,4 +3,21 @@ resource "terraform_data" "config_version" { global_records = sha256(jsonencode(var.global_records)) entry_records = sha256(jsonencode(var.entry_records)) }) + + lifecycle { + precondition { + condition = toset(keys(var.runner_config_access_scope_prefixes)) == var.entry_ids && alltrue([for prefix in values(var.runner_config_access_scope_prefixes) : trimspace(prefix) != ""]) + error_message = "runner_config_access_scope_prefixes must contain one non-empty prefix for every entry_id." + } + + precondition { + condition = var.runner_state_ttl_seconds > var.runner_config_ttl_seconds && floor(var.runner_state_ttl_seconds) == var.runner_state_ttl_seconds + error_message = "runner_state_ttl_seconds must be an integer greater than runner_config_ttl_seconds." + } + + precondition { + condition = toset(keys(var.entry_records)) == var.entry_ids + error_message = "entry_records must contain exactly one durable bootstrap record for every entry_id." + } + } } diff --git a/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl b/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl index e2afb15ae1..2aac0c62b8 100644 --- a/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl +++ b/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl @@ -243,3 +243,42 @@ run "storage_version_tracks_entry_record_changes" { error_message = "A durable entry-record update must publish the replacement storage resource ID to every Lambda capability." } } + +run "rejects_missing_runner_config_access_scope_prefix" { + command = plan + + variables { + runner_config_access_scope_prefixes = { + linux = "arn:aws:ec2:eu-west-1:123456789012:instance/" + } + } + + expect_failures = [terraform_data.config_version] +} + +run "rejects_runner_state_ttl_not_greater_than_runner_config_ttl" { + command = plan + + variables { + runner_state_ttl_seconds = 3600 + } + + expect_failures = [terraform_data.config_version] +} + +run "rejects_missing_entry_record" { + command = plan + + variables { + entry_records = { + linux = { + run_as = "runner" + agent_mode = "ephemeral" + disable_default_labels = false + enable_jit_config = true + } + } + } + + expect_failures = [terraform_data.config_version] +} diff --git a/modules/storage-providers/aws/dynamodb/variables.tf b/modules/storage-providers/aws/dynamodb/variables.tf index eeb490e61c..dc4e90badc 100644 --- a/modules/storage-providers/aws/dynamodb/variables.tf +++ b/modules/storage-providers/aws/dynamodb/variables.tf @@ -17,11 +17,6 @@ variable "entry_ids" { variable "runner_config_access_scope_prefixes" { description = "Per-entry compute-resource scope prefixes used to constrain one-time runner-config writes." type = map(string) - - validation { - condition = toset(keys(var.runner_config_access_scope_prefixes)) == var.entry_ids && alltrue([for prefix in values(var.runner_config_access_scope_prefixes) : trimspace(prefix) != ""]) - error_message = "runner_config_access_scope_prefixes must contain one non-empty prefix for every entry_id." - } } variable "runner_config_ttl_seconds" { @@ -37,11 +32,6 @@ variable "runner_config_ttl_seconds" { variable "runner_state_ttl_seconds" { description = "Safety TTL in seconds applied only while lifecycle records are provisioning or terminating; active and orphan inventory has no expiry." type = number - - validation { - condition = var.runner_state_ttl_seconds > var.runner_config_ttl_seconds && floor(var.runner_state_ttl_seconds) == var.runner_state_ttl_seconds - error_message = "runner_state_ttl_seconds must be an integer greater than runner_config_ttl_seconds." - } } variable "global_records" { @@ -62,11 +52,6 @@ variable "entry_records" { disable_default_labels = bool enable_jit_config = bool })) - - validation { - condition = toset(keys(var.entry_records)) == var.entry_ids - error_message = "entry_records must contain exactly one durable bootstrap record for every entry_id." - } } variable "config" {