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