diff --git a/README.md b/README.md
index f097d3ebd2..2b567bb13a 100644
--- a/README.md
+++ b/README.md
@@ -223,6 +223,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [runners\_scale\_up\_lambda\_memory\_size](#input\_runners\_scale\_up\_lambda\_memory\_size) | Memory size limit in MB for scale-up lambda. | `number` | `512` | 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 |
+| [scale\_down\_idle\_confirmation\_seconds](#input\_scale\_down\_idle\_confirmation\_seconds) | Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale (it can read false for a runner that is actively executing a job), so a single not-busy reading is not sufficient evidence a runner is idle. Set to at least one scale-down schedule interval to require two consecutive not-busy evaluations; a busy reading resets the window. 0 keeps the previous single-reading behaviour. | `number` | `0` | no |
| [scale\_down\_schedule\_expression](#input\_scale\_down\_schedule\_expression) | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no |
| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` | [| no | | [scale\_up\_reserved\_concurrent\_executions](#input\_scale\_up\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 61a558389f..6a52122208 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -198,7 +198,7 @@ multi_runner_config = { | [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
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")
}) | `{}` | no |
| [global\_config\_lambda](#input\_global\_config\_lambda) | Global Lambda configuration shared by all runner lanes.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)
}), {})
}) | `{}` | no |
| [global\_config\_observability](#input\_global\_config\_observability) | Global observability configuration shared by all runner lanes.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({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
-| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = 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
})
}), {})
}), {})
}) | `{}` | no |
+| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = 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_confirmation_seconds = optional(number, 0)
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
})
}), {})
}), {})
}) | `{}` | no |
| [global\_config\_ssm](#input\_global\_config\_ssm) | Global SSM configuration shared by all runner lanes.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)
}), {})
}), {})
}) | `{}` | 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 |
@@ -220,7 +220,7 @@ multi_runner_config = {
| [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) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.map(object({
# V1 contract
runner_config = optional(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
})
}), null)
matcherConfig = optional(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)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
# V2 Contract
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 = optional(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 = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = 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)
}), 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({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})
compute_provider = optional(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 = optional(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)
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)
}), {})
}), {})
})) | n/a | yes |
+| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.map(object({
# V1 contract
runner_config = optional(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)
scale_down_idle_confirmation_seconds = optional(number, 0)
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
})
}), null)
matcherConfig = optional(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)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
# V2 Contract
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 = optional(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 = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = 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)
}), 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_confirmation_seconds = 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({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})
compute_provider = optional(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 = optional(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)
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)
}), {})
}), {})
})) | n/a | yes |
| [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-config/README.md b/modules/runner-config/README.md
new file mode 100644
index 0000000000..c918a79fa0
--- /dev/null
+++ b/modules/runner-config/README.md
@@ -0,0 +1,133 @@
+# 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_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_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 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 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
+
+`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_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.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
+
+### 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_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"
+
+## 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.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 |
+| [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 |
+| [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
+
+| 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.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)
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.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.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.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({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
+| [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.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.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.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\_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. |
+| [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..660fcc60ab
--- /dev/null
+++ b/modules/runner-config/common-config.tf
@@ -0,0 +1,44 @@
+# 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" {}
diff --git a/modules/runner-config/compute-provider.aws.ec2.tf b/modules/runner-config/compute-provider.aws.ec2.tf
new file mode 100644
index 0000000000..5d053a73dc
--- /dev/null
+++ b/modules/runner-config/compute-provider.aws.ec2.tf
@@ -0,0 +1,37 @@
+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_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.aws.ec2
+ runner = merge(var.runner, {
+ iam = merge(var.runner.iam, {
+ role = local.runner_role
+ managed_policy_arns = local.common_runner_managed_policy_arns
+ })
+ })
+ github = var.github
+ 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
new file mode 100644
index 0000000000..bffc43b814
--- /dev/null
+++ b/modules/runner-config/compute-provider.tf
@@ -0,0 +1,29 @@
+locals {
+ compute_providers = {
+ aws_ec2 = var.compute_provider.aws.ec2
+ }
+
+ 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"
+ }
+
+ provider_type = local.provider_types[local.provider_key]
+
+ provider_assume_role_policies = {
+ 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_key]
+
+ provider_contracts = {
+ aws_ec2 = one(module.compute_aws_ec2[*].provider)
+ }
+
+ provider_contract = local.provider_contracts[local.provider_key]
+}
diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf
new file mode 100644
index 0000000000..25994beaba
--- /dev/null
+++ b/modules/runner-config/orchestration-provider.tf
@@ -0,0 +1,73 @@
+locals {
+ orchestration_providers = {
+ for provider_type, provider_config in var.orchestration_provider : 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"
+ }
+
+ orchestration_provider_runner_lifecycle = {
+ webhook = one(module.orchestration_webhook[*].runner_lifecycle)
+ }[local.orchestration_provider_type]
+}
+
+module "orchestration_webhook" {
+ source = "../orchestration-providers/webhook"
+ count = local.orchestration_provider_enabled.webhook ? 1 : 0
+
+ aws_partition = var.aws_partition
+ prefix = var.prefix
+ tags = var.tags
+
+ config = var.orchestration_provider.webhook
+ 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..486e3261eb
--- /dev/null
+++ b/modules/runner-config/outputs.tf
@@ -0,0 +1,42 @@
+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(module.orchestration_webhook[*].scale_up)
+}
+
+output "scale_down" {
+ description = "Scale-down control-plane resources. Null when webhook orchestration is not configured."
+ value = one(module.orchestration_webhook[*].scale_down)
+}
+
+output "pool" {
+ description = "Scheduled pool resources. Null when no pool configuration is supplied."
+ value = one(module.orchestration_webhook[*].pool)
+}
+
+output "orchestration_provider" {
+ description = "Resources grouped under the selected runner orchestration provider."
+ value = {
+ webhook = local.orchestration_provider_enabled.webhook ? {
+ 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
+ }
+}
+
+output "provider" {
+ description = "Provider-specific resources grouped under the selected provider namespace and type."
+ value = {
+ aws = {
+ ec2 = local.provider_key == "aws_ec2" ? local.provider_contract.resources : null
+ }
+ }
+}
diff --git a/modules/runner-config/runner-role.tf b/modules/runner-config/runner-role.tf
new file mode 100644
index 0000000000..6baa1e4206
--- /dev/null
+++ b/modules/runner-config/runner-role.tf
@@ -0,0 +1,48 @@
+locals {
+ # 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
+
+ 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)
+ managed = local.create_runner_role
+ }
+
+ common_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"
+ } : {},
+ )
+
+ 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 = local.provider_assume_role_policy
+ path = local.runner_role_path
+ permissions_boundary = var.runner.iam.permissions_boundary
+ tags = local.runner_tags
+}
+
+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.provider_runner_policies.managed_policy_arns : {}
+
+ role = aws_iam_role.runner[0].name
+ policy_arn = each.value
+}
diff --git a/modules/runner-config/runner-ssm-parameters.tf b/modules/runner-config/runner-ssm-parameters.tf
new file mode 100644
index 0000000000..1d97c908a8
--- /dev/null
+++ b/modules/runner-config/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 = local.orchestration_provider_runner_lifecycle.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 = local.orchestration_provider_runner_lifecycle.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-config/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper.tf
new file mode 100644
index 0000000000..5bb31bb7b5
--- /dev/null
+++ b/modules/runner-config/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 = {
+ # The housekeeper resolves only its component-owned selector and never
+ # inherits the selected orchestration provider's runner-control artifact.
+ artifact = local.ssm_housekeeper_artifact
+ 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
+ principals = var.lambda.principals
+ }
+ }
+ 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-config/ssm-housekeeper/README.md b/modules/runner-config/ssm-housekeeper/README.md
new file mode 100644
index 0000000000..5f5d1ad166
--- /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.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/ssm-housekeeper/iam-policies.tf b/modules/runner-config/ssm-housekeeper/iam-policies.tf
new file mode 100644
index 0000000000..8d3bab2865
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/iam-policies.tf
@@ -0,0 +1,58 @@
+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" "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"
+ 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-config/ssm-housekeeper/outputs.tf b/modules/runner-config/ssm-housekeeper/outputs.tf
new file mode 100644
index 0000000000..064f5a1ab1
--- /dev/null
+++ b/modules/runner-config/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-config/ssm-housekeeper/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper/ssm-housekeeper.tf
new file mode 100644
index 0000000000..bcafed201a
--- /dev/null
+++ b/modules/runner-config/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-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl b/modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl
new file mode 100644
index 0000000000..bac30c6752
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl
@@ -0,0 +1,263 @@
+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-config/"
+ permissions_boundary = null
+ principals = [{
+ type = "AWS"
+ identifiers = ["arn:aws-us-gov:iam::123456789012:role/local-testing"]
+ }]
+ }
+ }
+ 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 = (
+ 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)" &&
+ 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-config/"
+ 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."
+ }
+
+ 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-config/ssm-housekeeper/variables.tf b/modules/runner-config/ssm-housekeeper/variables.tf
new file mode 100644
index 0000000000..64848fc33c
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/variables.tf
@@ -0,0 +1,93 @@
+variable "config" {
+ description = <<-EOT
+ 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.
+ 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)
+ 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)
+ })
+ })
+
+ nullable = false
+}
diff --git a/modules/runner-config/ssm-housekeeper/versions.tf b/modules/runner-config/ssm-housekeeper/versions.tf
new file mode 100644
index 0000000000..da9769f550
--- /dev/null
+++ b/modules/runner-config/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-config/tests/README.md b/modules/runner-config/tests/README.md
new file mode 100644
index 0000000000..fa55dfecd9
--- /dev/null
+++ b/modules/runner-config/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 = object({
arn = string
id = string
key = string
}) | n/a | yes |
+| [scale\_down\_idle\_confirmation\_seconds](#input\_scale\_down\_idle\_confirmation\_seconds) | Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale (it can read false for a runner that is actively executing a job), so a single not-busy reading is not sufficient evidence a runner is idle. Set to at least one scale-down schedule interval to require two consecutive not-busy evaluations; a busy reading resets the window. 0 keeps the previous single-reading behaviour. | `number` | `0` | no |
| [scale\_down\_schedule\_expression](#input\_scale\_down\_schedule\_expression) | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no |
| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` | [| no | | [scale\_up\_reserved\_concurrent\_executions](#input\_scale\_up\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no |
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]