diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index b4edf6f35e..0b61d75c7e 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -53,6 +53,7 @@ jobs: - default - ephemeral - multi-runner + - multi-runner-v2 - termination-watcher terraform: - "1.4.0" diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 6d91e10c66..405f05388a 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -85,6 +85,8 @@ jobs: "download-lambda", "lambda", "multi-runner", + "compute-providers/aws/microvm", + "compute-providers/aws/microvm/trust-policy", "runner-binaries-syncer", "runners", "setup-iam-permissions", @@ -155,6 +157,7 @@ jobs: "ephemeral", "termination-watcher", "multi-runner", + "multi-runner-v2", "external-managed-ssm-secrets" ] defaults: @@ -215,6 +218,8 @@ jobs: module: - modules/runners - modules/multi-runner + - modules/compute-providers/aws/microvm + - modules/compute-providers/aws/microvm/trust-policy defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/examples/index.md b/docs/examples/index.md index aee1d868b0..f0558966bd 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -5,6 +5,7 @@ Examples are located in the [examples](https://github.com/github-aws-runners/ter - _[Default](default.md)_: The default example of the module - _[Ephemeral](ephemeral.md)_: Example usages of ephemeral runners based on the default example. - _[Multi Runner](multi-runner.md)_ : Example usage of creating a multi runner which creates multiple runners/ configurations with a single deployment. The examples including: "arm64", "windows", and "ubuntu" runners. +- _[Multi Runner v2](multi-runner-v2.md)_ : Example usage of the experimental v2 multi-runner configuration interface with shared defaults and per-lane overrides. - _[Permissions boundary](permissions-boundary.md)_: Example usages of permissions boundaries. - _[Prebuilt Images](prebuilt.md)_: Example usages of deploying runners with a custom prebuilt image. - _[Termination watcher](termination-watcher.md)_: Example usages of termination watcher. diff --git a/docs/examples/multi-runner-v2.md b/docs/examples/multi-runner-v2.md new file mode 100644 index 0000000000..565b601ecb --- /dev/null +++ b/docs/examples/multi-runner-v2.md @@ -0,0 +1 @@ +--8<-- "examples/multi-runner-v2/README.md" diff --git a/examples/multi-runner-v2/.terraform.lock.hcl b/examples/multi-runner-v2/.terraform.lock.hcl new file mode 100644 index 0000000000..c96d2b19bf --- /dev/null +++ b/examples/multi-runner-v2/.terraform.lock.hcl @@ -0,0 +1,93 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + hashes = [ + "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", + "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", + "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", + "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", + "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a", + "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1", + "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d", + "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c", + "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb", + "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9", + "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd", + "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb", + "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925", + "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04", + "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54", + "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.9.0" + constraints = "~> 2.0" + hashes = [ + "h1:9rBZCMNpxKwMlRbWH2QpwD3kqUCAejdOZQ/aiiDObXQ=", + "h1:m24fjcInWvTVZ1XSo2MaNuKPe+X/gfG8SIi09rA7a7M=", + "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0", + "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64", + "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90", + "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c", + "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8", + "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8", + "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b", + "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9", + "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195", + "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3", + "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.3.1" + constraints = "~> 3.0, ~> 3.2" + hashes = [ + "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=", + "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=", + "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44", + "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451", + "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05", + "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3", + "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7", + "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1", + "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be", + "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891", + "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5", + "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610", + "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/examples/multi-runner-v2/README.md b/examples/multi-runner-v2/README.md new file mode 100644 index 0000000000..f8cea507ec --- /dev/null +++ b/examples/multi-runner-v2/README.md @@ -0,0 +1,74 @@ +# Multi-runner v2 example + +This example demonstrates the experimental multi-runner v2 interface. Shared +defaults are configured with `global_config*` variables, while +each runner lane uses `multi_runner_config` for its matcher, +runner lifecycle, and compute-provider settings. + +The example creates three lanes from one deployment: + +- Linux ARM64 Amazon Linux runners. +- Ephemeral Linux x64 Amazon Linux runners with job retry enabled. +- Windows x64 Server Core 2022 runners. + +The v2 interface keeps provider-owned settings inside the selected provider +configuration. For example, VPC and subnet settings are under +`global_config_compute_provider.aws.ec2`, while the per-lane +instance types and AMI filter are under each lane's compute provider block. + +Configure the GitHub App variables before applying: + +```bash +terraform init +terraform apply \ + -var='github_app={id="123456",key_base64="..."}' +``` + +The `github_app` value is sensitive and should be supplied through a secure +variable source in real deployments rather than committed to configuration. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.33 | +| [local](#requirement\_local) | ~> 2.0 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | 3.9.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [runners](#module\_runners) | ../../modules/multi-runner | n/a | +| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../modules/webhook-github-app | n/a | + +## Resources + +| Name | Type | +|------|------| +| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_region](#input\_aws\_region) | AWS region to deploy to. | `string` | `"eu-west-1"` | no | +| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no | +| [github\_app](#input\_github\_app) | GitHub App ID and base64-encoded private key. |
object({
id = string
key_base64 = string
}) | n/a | yes |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [webhook\_endpoint](#output\_webhook\_endpoint) | n/a |
+| [webhook\_secret](#output\_webhook\_secret) | n/a |
+
diff --git a/examples/multi-runner-v2/main.tf b/examples/multi-runner-v2/main.tf
new file mode 100644
index 0000000000..ca740ca078
--- /dev/null
+++ b/examples/multi-runner-v2/main.tf
@@ -0,0 +1,173 @@
+locals {
+ environment = var.environment != null ? var.environment : "multi-runner-v2"
+ aws_region = var.aws_region
+}
+
+resource "random_id" "random" {
+ byte_length = 20
+}
+
+module "base" {
+ source = "../base"
+
+ prefix = local.environment
+ aws_region = local.aws_region
+}
+
+module "runners" {
+ source = "../../modules/multi-runner"
+
+ prefix = local.environment
+ aws_region = local.aws_region
+
+ global_config = {
+ tags = {
+ Example = local.environment
+ Project = "ProjectX"
+ }
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ extra_labels = ["v2"]
+ }
+ }
+
+ global_config_github = {
+ app = {
+ key_base64 = var.github_app.key_base64
+ id = var.github_app.id
+ webhook_secret = random_id.random.hex
+ }
+ }
+
+ global_config_lambda = {
+ architecture = "arm64"
+ }
+
+ global_config_orchestration_provider = {
+ webhook = {
+ eventbridge = {
+ enabled = true
+ accept_events = ["workflow_job"]
+ }
+ }
+ }
+
+ global_config_compute_provider = {
+ aws = {
+ ec2 = {
+ vpc_id = module.base.vpc.vpc_id
+ subnet_ids = module.base.vpc.private_subnets
+ ssm_enabled = true
+ runner_binaries = {
+ enabled = true
+ }
+ }
+ }
+ }
+
+ multi_runner_config = {
+ linux-arm64 = {
+ runner = {
+ architecture = "arm64"
+ name_prefix = "amazon-arm64-"
+ extra_labels = ["amazon"]
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ maximum_count = 1
+ }
+ matcherConfig = {
+ exactMatch = true
+ labelMatchers = [["self-hosted", "linux", "arm64", "amazon"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["t4g.large", "c6g.large"]
+ }
+ }
+ }
+ }
+
+ linux-x64 = {
+ runner = {
+ name_prefix = "amazon-x64-"
+ extra_labels = ["amazon"]
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ ephemeral = true
+ maximum_count = 1
+ }
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "linux", "x64", "amazon"]]
+ exactMatch = false
+ priority = 1
+ }
+ queue = {
+ delay_webhook_event = 0
+ }
+ job_retry = {
+ enabled = true
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5a.large", "m5ad.large"]
+ }
+ }
+ }
+ }
+
+ windows-x64 = {
+ runner = {
+ os = "windows"
+ name_prefix = "windows-x64-"
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ boot_time_in_minutes = 20
+ maximum_count = 1
+ }
+ matcherConfig = {
+ exactMatch = true
+ labelMatchers = [["self-hosted", "windows", "x64", "servercore-2022"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large", "c5.large"]
+ ami = {
+ filter = {
+ name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"]
+ state = ["available"]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+module "webhook_github_app" {
+ source = "../../modules/webhook-github-app"
+ depends_on = [module.runners]
+
+ github_app = {
+ key_base64 = var.github_app.key_base64
+ id = var.github_app.id
+ webhook_secret = random_id.random.hex
+ }
+ webhook_endpoint = module.runners.webhook.endpoint
+}
diff --git a/examples/multi-runner-v2/outputs.tf b/examples/multi-runner-v2/outputs.tf
new file mode 100644
index 0000000000..1feaf2e671
--- /dev/null
+++ b/examples/multi-runner-v2/outputs.tf
@@ -0,0 +1,8 @@
+output "webhook_endpoint" {
+ value = module.runners.webhook.endpoint
+}
+
+output "webhook_secret" {
+ sensitive = true
+ value = random_id.random.hex
+}
diff --git a/examples/multi-runner-v2/providers.tf b/examples/multi-runner-v2/providers.tf
new file mode 100644
index 0000000000..eca2fe96a7
--- /dev/null
+++ b/examples/multi-runner-v2/providers.tf
@@ -0,0 +1,9 @@
+provider "aws" {
+ region = local.aws_region
+
+ default_tags {
+ tags = {
+ Example = local.environment
+ }
+ }
+}
diff --git a/examples/multi-runner-v2/variables.tf b/examples/multi-runner-v2/variables.tf
new file mode 100644
index 0000000000..2a7f7eda54
--- /dev/null
+++ b/examples/multi-runner-v2/variables.tf
@@ -0,0 +1,23 @@
+variable "github_app" {
+ description = "GitHub App ID and base64-encoded private key."
+
+ type = object({
+ id = string
+ key_base64 = string
+ })
+ sensitive = true
+}
+
+variable "environment" {
+ description = "Environment name, used as prefix."
+
+ type = string
+ default = null
+}
+
+variable "aws_region" {
+ description = "AWS region to deploy to."
+
+ type = string
+ default = "eu-west-1"
+}
diff --git a/examples/multi-runner-v2/versions.tf b/examples/multi-runner-v2/versions.tf
new file mode 100644
index 0000000000..1dfb3e5774
--- /dev/null
+++ b/examples/multi-runner-v2/versions.tf
@@ -0,0 +1,17 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.33"
+ }
+ local = {
+ source = "hashicorp/local"
+ version = "~> 2.0"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+ required_version = ">= 1.4.0"
+}
diff --git a/examples/multi-runner/README.md b/examples/multi-runner/README.md
index 5bb10f7248..960030c099 100644
--- a/examples/multi-runner/README.md
+++ b/examples/multi-runner/README.md
@@ -56,7 +56,7 @@ terraform output -raw webhook_secret
| Name | Version |
|------|---------|
-| [terraform](#requirement\_terraform) | >= 1.3.0 |
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
diff --git a/examples/multi-runner/versions.tf b/examples/multi-runner/versions.tf
index 666b978aac..1dfb3e5774 100644
--- a/examples/multi-runner/versions.tf
+++ b/examples/multi-runner/versions.tf
@@ -13,5 +13,5 @@ terraform {
version = "~> 3.0"
}
}
- required_version = ">= 1.3.0"
+ required_version = ">= 1.4.0"
}
diff --git a/mkdocs.yaml b/mkdocs.yaml
index 849b9a53dc..f9680b9d2b 100644
--- a/mkdocs.yaml
+++ b/mkdocs.yaml
@@ -77,6 +77,7 @@ nav:
- Overview: examples/index.md
- Default: examples/default.md
- Multi Runner: examples/multi-runner.md
+ - Multi Runner v2: examples/multi-runner-v2.md
- Ephemeral: examples/ephemeral.md
- External managed secrets: examples/external-managed-ssm-secrets.md
- Custom AMI: examples/prebuilt.md
diff --git a/modules/compute-providers/aws/microvm/README.md b/modules/compute-providers/aws/microvm/README.md
new file mode 100644
index 0000000000..07ecf5d50f
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/README.md
@@ -0,0 +1,70 @@
+# AWS Lambda MicroVM runner provider
+
+This internal module implements the AWS Lambda MicroVM compute provider used by `runner-config`. It returns provider-specific Lambda environment variables, control-plane IAM policy fragments, selected image metadata, native runtime and optional CloudWatch-agent log groups, and collected-file definitions through the common provider contract; the parent owns the runner role, Lambda resources, queues, schedules, and Parameter Store lifecycle.
+
+Select it with the `compute_provider.aws.microvm` leaf. The Terraform dispatch key is `aws_microvm`, while the runtime `COMPUTE_PROVIDER_TYPE` remains `microvm` for compatibility with the control-plane Lambda. MicroVM lanes require Linux on ARM64 and ephemeral webhook orchestration with just-in-time configuration enabled.
+
+MicroVM runners use the provider's fixed 28,800-second (8-hour) lifetime; this is not a Terraform input.
+
+The resolved provider-neutral `runner.iam.role` is passed to Lambda as the MicroVM execution role. The provider creates `/github-self-hosted-runners/object({
image_arn = string
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), [])
egress_network_connectors = optional(list(string), [])
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
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)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}) | n/a | yes |
+| [github](#input\_github) | GitHub Enterprise Server settings available to compute-provider bootstrap data.object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
}) | `{}` | no |
+| [observability](#input\_observability) | Provider-neutral observability settings applied to the provider-managed MicroVM runtime log group.object({
logs = optional(object({
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
}) | `{}` | no |
+| [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no |
+| [runner](#input\_runner) | Resolved runner settings consumed by the Lambda MicroVM compute provider.object({
os = optional(string, "linux")
architecture = optional(string, "arm64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
}) | n/a | yes |
+| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.object({
paths = object({
root = string
tokens = string
config = string
})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
}) | n/a | yes |
+| [tags](#input\_tags) | Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. |
+| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. |
+| [provider](#output\_provider) | Nested Lambda MicroVM compute-provider contract consumed by runner-config. |
+| [resources](#output\_resources) | Provider-specific MicroVM resources exposed by runner-config. |
+
diff --git a/modules/compute-providers/aws/microvm/control-plane.tf b/modules/compute-providers/aws/microvm/control-plane.tf
new file mode 100644
index 0000000000..832a90df04
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/control-plane.tf
@@ -0,0 +1,118 @@
+data "aws_iam_policy_document" "scale_up" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "lambda:ListMicrovms",
+ "lambda:PassNetworkConnector",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "lambda:RunMicrovm",
+ "lambda:TerminateMicrovm",
+ ]
+ resources = local.microvm_image_resource_arns
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:AddTagsToResource",
+ "ssm:DeleteParameter",
+ "ssm:PutParameter",
+ ]
+ resources = [local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParametersByPath"]
+ resources = [local.microvm_metadata_path_arn, local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameters"]
+ resources = [local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["iam:PassRole"]
+ resources = [var.runner.iam.role.arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:DeleteParameter"]
+ resources = [local.runner_token_path_arn]
+ }
+}
+
+data "aws_iam_policy_document" "scale_down" {
+ statement {
+ effect = "Allow"
+ actions = ["lambda:ListMicrovms"]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["lambda:TerminateMicrovm"]
+ resources = local.microvm_image_resource_arns
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:DeleteParameter",
+ "ssm:PutParameter",
+ ]
+ resources = [local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParametersByPath"]
+ resources = [local.microvm_metadata_path_arn, local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:DeleteParameter"]
+ resources = [local.runner_token_path_arn]
+ }
+}
+
+locals {
+ microvm_metadata_ssm_path = "${local.ssm_config_ssm_path}/microvm-metadata"
+ microvm_metadata_path_arn = "${local.ssm_parameter_arn_prefix}${local.microvm_metadata_ssm_path}"
+ microvm_metadata_parameter_arn = "${local.microvm_metadata_path_arn}/*"
+ microvm_image_resource_arns = coalesce(
+ var.config.iam.resource_arns.images,
+ [var.config.image_arn],
+ )
+ runner_jit_ssm_path = "/${trim(var.ssm.paths.root, "/")}/${trim(var.ssm.paths.tokens, "/")}"
+
+ microvm_custom_environment_variables = {
+ for key, value in var.config.environment_variables : key => value
+ if !contains(["MICROVM_METADATA_TAGS", "MICROVM_RUNNER_CONFIG_SSM_ARN"], key)
+ }
+ microvm_environment_variables = merge(local.microvm_custom_environment_variables, {
+ MICROVM_EGRESS_NETWORK_CONNECTORS = length(var.config.egress_network_connectors) == 0 ? "" : jsonencode(var.config.egress_network_connectors)
+ MICROVM_EXECUTION_ROLE_ARN = var.runner.iam.role.arn
+ MICROVM_IMAGE_ARN = var.config.image_arn
+ MICROVM_IMAGE_VERSION = var.config.image_version == null ? "" : var.config.image_version
+ MICROVM_INGRESS_NETWORK_CONNECTORS = length(var.config.ingress_network_connectors) == 0 ? "" : jsonencode(var.config.ingress_network_connectors)
+ MICROVM_LOG_GROUP = aws_cloudwatch_log_group.runtime.name
+ MICROVM_METADATA_SSM_PATH = local.microvm_metadata_ssm_path
+ SSM_TOKEN_PATH = local.runner_jit_ssm_path
+ })
+
+ scale_up_environment_variables = local.microvm_environment_variables
+ scale_down_environment_variables = local.microvm_environment_variables
+ pool_environment_variables = local.microvm_environment_variables
+}
diff --git a/modules/compute-providers/aws/microvm/logging.tf b/modules/compute-providers/aws/microvm/logging.tf
new file mode 100644
index 0000000000..18f87ffeed
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/logging.tf
@@ -0,0 +1,86 @@
+locals {
+ provider_tags = merge(
+ {
+ "Name" = format("%s-action-runner", var.prefix)
+ },
+ var.tags,
+ )
+
+ log_group_tags = merge(
+ local.provider_tags,
+ var.observability.logs.tags,
+ )
+
+ ssm_config_ssm_path = "/${trim(var.ssm.paths.root, "/")}/${trim(var.ssm.paths.config, "/")}"
+ ssm_parameter_tags = merge(
+ local.provider_tags,
+ var.ssm.tags,
+ var.ssm.parameters.tags,
+ )
+
+ runner_log_files = var.config.log_files != null ? var.config.log_files : [
+ {
+ log_group_name = "internal_service"
+ prefix_log_group = true
+ file_path = "/var/log/microvm/internal-services.log"
+ log_stream_name = "{microvm_id}"
+ log_class = "STANDARD"
+ },
+ {
+ log_group_name = "run"
+ prefix_log_group = true
+ file_path = "/var/log/microvm/run.log"
+ log_stream_name = "{microvm_id}"
+ log_class = "STANDARD"
+ },
+ {
+ log_group_name = "runner"
+ prefix_log_group = true
+ file_path = "/opt/actions-runner/_diag/Runner_**.log"
+ log_stream_name = "{microvm_id}"
+ log_class = "STANDARD"
+ },
+ ]
+
+ logfiles = var.config.cloudwatch_agent.enabled ? [for log_file in local.runner_log_files : {
+ log_group_name = log_file.prefix_log_group ? "/github-self-hosted-runners/${var.prefix}/${log_file.log_group_name}" : "/${log_file.log_group_name}"
+ log_stream_name = log_file.log_stream_name
+ file_path = log_file.file_path
+ log_group_class = log_file.log_class
+ }] : []
+ runner_log_group_names = distinct([for log_file in local.logfiles : log_file.log_group_name])
+ runner_log_group_classes = [for name in local.runner_log_group_names : [
+ for log_file in local.logfiles : log_file.log_group_class
+ if log_file.log_group_name == name
+ ][0]]
+}
+
+resource "aws_cloudwatch_log_group" "runtime" {
+ name = "/github-self-hosted-runners/${var.prefix}/microvm"
+ retention_in_days = var.observability.logs.retention_in_days
+ kms_key_id = var.observability.logs.kms_key_id
+ log_group_class = var.observability.logs.class
+ tags = local.log_group_tags
+}
+
+resource "aws_ssm_parameter" "cloudwatch_agent_config_runner" {
+ count = var.config.cloudwatch_agent.enabled ? 1 : 0
+
+ name = "${local.ssm_config_ssm_path}/cloudwatch_agent_config_runner"
+ type = "String"
+ value = var.config.cloudwatch_agent.config != null ? var.config.cloudwatch_agent.config : templatefile(
+ "${path.module}/templates/cloudwatch_config.json",
+ { logfiles = jsonencode(local.logfiles) },
+ )
+ tags = local.ssm_parameter_tags
+}
+
+resource "aws_cloudwatch_log_group" "gh_runners" {
+ count = length(local.runner_log_group_names)
+
+ name = local.runner_log_group_names[count.index]
+ retention_in_days = var.observability.logs.retention_in_days
+ kms_key_id = var.observability.logs.kms_key_id
+ log_group_class = local.runner_log_group_classes[count.index]
+ tags = local.log_group_tags
+}
diff --git a/modules/compute-providers/aws/microvm/outputs.tf b/modules/compute-providers/aws/microvm/outputs.tf
new file mode 100644
index 0000000000..6200a8f54e
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/outputs.tf
@@ -0,0 +1,23 @@
+output "environment_variables" {
+ description = "Provider-specific Lambda environment variable fragments consumed by runner-config."
+ value = local.provider_environment_variables
+}
+
+output "policies" {
+ description = "Provider-specific IAM policy fragments consumed by runner-config."
+ value = local.provider_policies
+}
+
+output "resources" {
+ description = "Provider-specific MicroVM resources exposed by runner-config."
+ value = local.provider_resources
+}
+
+output "provider" {
+ description = "Nested Lambda MicroVM compute-provider contract consumed by runner-config."
+ value = {
+ environment_variables = local.provider_environment_variables
+ policies = local.provider_policies
+ resources = local.provider_resources
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/policies-runner.tf b/modules/compute-providers/aws/microvm/policies-runner.tf
new file mode 100644
index 0000000000..9b514e396a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/policies-runner.tf
@@ -0,0 +1,81 @@
+data "aws_caller_identity" "current" {}
+
+locals {
+ ssm_parameter_arn_prefix = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter"
+ runner_token_path_arn = "${local.ssm_parameter_arn_prefix}/${trim(var.ssm.paths.root, "/")}/${trim(var.ssm.paths.tokens, "/")}/*"
+ runner_metadata_tags_arn = "${local.microvm_metadata_path_arn}/*.tags"
+ runner_enable_cloudwatch_arn = "${local.ssm_parameter_arn_prefix}${local.ssm_config_ssm_path}/enable_cloudwatch"
+ runner_cloudwatch_config_arn = "${local.ssm_parameter_arn_prefix}${local.ssm_config_ssm_path}/cloudwatch_agent_config_runner"
+ runner_cloudwatch_log_group_arns = [for name in local.runner_log_group_names :
+ "arn:${var.aws_partition}:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:${name}"
+ ]
+ runner_inline_policies = merge({
+ ssm_jit = {
+ name = "runner-microvm-ssm-jit"
+ policy_json = data.aws_iam_policy_document.runner_ssm_jit.json
+ }
+ runtime_logs = {
+ name = "runner-microvm-runtime-logs"
+ policy_json = data.aws_iam_policy_document.runner_runtime_logs.json
+ }
+ runner_metadata = {
+ name = "runner-microvm-metadata"
+ policy_json = data.aws_iam_policy_document.runner_metadata.json
+ }
+ }, var.config.cloudwatch_agent.enabled ? {
+ cloudwatch = {
+ name = "runner-microvm-cloudwatch"
+ policy_json = data.aws_iam_policy_document.runner_cloudwatch[0].json
+ }
+ } : {})
+}
+
+data "aws_iam_policy_document" "runner_ssm_jit" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:DeleteParameter",
+ "ssm:GetParameter",
+ ]
+ resources = [local.runner_token_path_arn]
+ }
+}
+
+data "aws_iam_policy_document" "runner_metadata" {
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameter"]
+ resources = [local.runner_metadata_tags_arn, local.runner_enable_cloudwatch_arn]
+ }
+}
+
+data "aws_iam_policy_document" "runner_cloudwatch" {
+ count = var.config.cloudwatch_agent.enabled ? 1 : 0
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameter"]
+ resources = [local.runner_cloudwatch_config_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "logs:CreateLogStream",
+ "logs:DescribeLogStreams",
+ "logs:PutLogEvents",
+ ]
+ resources = [for arn in local.runner_cloudwatch_log_group_arns : "${arn}:*"]
+ }
+}
+
+data "aws_iam_policy_document" "runner_runtime_logs" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ ]
+ resources = ["${aws_cloudwatch_log_group.runtime.arn}:*"]
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/provider-contract.tf b/modules/compute-providers/aws/microvm/provider-contract.tf
new file mode 100644
index 0000000000..2f487a01d7
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/provider-contract.tf
@@ -0,0 +1,36 @@
+locals {
+ provider_environment_variables = {
+ scale_up = local.scale_up_environment_variables
+ scale_down = local.scale_down_environment_variables
+ pool = local.pool_environment_variables
+ }
+
+ provider_policies = {
+ runner = {
+ inline_policies = local.runner_inline_policies
+ managed_policy_arns = var.runner.iam.managed_policy_arns
+ }
+ scale_up = {
+ iam_policy_json = data.aws_iam_policy_document.scale_up.json
+ additional_iam_policy_json = var.config.iam.additional_policy_json.scale_up
+ managed_policy_enabled = var.config.iam.managed_policies.scale_up != null
+ managed_policy_arn = try(var.config.iam.managed_policies.scale_up.arn, null)
+ }
+ scale_down = {
+ iam_policy_json = data.aws_iam_policy_document.scale_down.json
+ }
+ pool = {
+ iam_policy_json = data.aws_iam_policy_document.scale_up.json
+ managed_policy_enabled = var.config.iam.managed_policies.pool != null
+ managed_policy_arn = try(var.config.iam.managed_policies.pool.arn, null)
+ }
+ }
+
+ provider_resources = {
+ image_arn = var.config.image_arn
+ image_version = var.config.image_version
+ execution_role_arn = var.runner.iam.role.arn
+ runners_log_groups = concat([aws_cloudwatch_log_group.runtime], aws_cloudwatch_log_group.gh_runners)
+ logfiles = local.logfiles
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/runner-config.tf b/modules/compute-providers/aws/microvm/runner-config.tf
new file mode 100644
index 0000000000..de3b775106
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/runner-config.tf
@@ -0,0 +1,6 @@
+resource "aws_ssm_parameter" "runner_enable_cloudwatch" {
+ name = "${local.ssm_config_ssm_path}/enable_cloudwatch"
+ type = "String"
+ value = var.config.cloudwatch_agent.enabled
+ tags = local.ssm_parameter_tags
+}
diff --git a/modules/compute-providers/aws/microvm/templates/cloudwatch_config.json b/modules/compute-providers/aws/microvm/templates/cloudwatch_config.json
new file mode 100644
index 0000000000..554de026e5
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/templates/cloudwatch_config.json
@@ -0,0 +1,12 @@
+{
+ "agent": {
+ "metrics_collection_interval": 5
+ },
+ "logs": {
+ "logs_collected": {
+ "files": {
+ "collect_list": ${logfiles}
+ }
+ }
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl
new file mode 100644
index 0000000000..de167845f2
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl
@@ -0,0 +1,633 @@
+mock_provider "aws" {
+ mock_data "aws_caller_identity" {
+ defaults = {
+ account_id = "123456789012"
+ }
+ }
+
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{}"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_log_group" {
+ defaults = {
+ arn = "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/microvm"
+ }
+ }
+}
+
+variables {
+ aws_region = "eu-west-1"
+ prefix = "microvm-test"
+
+ tags = {
+ Module = "runner"
+ Name = "module"
+ }
+
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ image_version = "3"
+ ingress_network_connectors = [
+ "arn:aws:lambda:eu-west-1:123456789012:network-connector:ingress",
+ ]
+ egress_network_connectors = [
+ "arn:aws:lambda:eu-west-1:123456789012:network-connector:egress",
+ ]
+ environment_variables = {
+ MICROVM_CLUSTER = "runner-cluster"
+ MICROVM_IMAGE_ARN = "caller-cannot-override-provider-contract"
+ MICROVM_METADATA_SSM_PATH = "/caller/cannot/override/provider-contract"
+ MICROVM_METADATA_TAGS = "retired-provider-contract"
+ MICROVM_RUNNER_CONFIG_SSM_ARN = "retired-provider-contract"
+ SSM_TOKEN_PATH = "/caller/cannot/override/token-path"
+ }
+ }
+
+ runner = {
+ name_prefix = "microvm-"
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/microvm-test-runner"
+ name = "microvm-test-runner"
+ }
+ managed_policy_arns = {
+ readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess"
+ }
+ }
+ }
+
+ ssm = {
+ paths = {
+ root = "/github-action-runners"
+ tokens = "tokens"
+ config = "config"
+ }
+ tags = {
+ Name = "ssm"
+ Precedence = "ssm"
+ Ssm = "shared"
+ }
+ parameters = {
+ tags = {
+ Name = "parameter"
+ Parameter = "metadata"
+ Precedence = "parameter"
+ "ghr:environment" = "caller-cannot-override"
+ "ghr:runner_name_prefix" = "caller-cannot-override"
+ "ghr:ssm_config_path" = "caller-cannot-override"
+ }
+ }
+ }
+
+ observability = {
+ logs = {
+ retention_in_days = 30
+ kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/runtime-logs"
+ class = "INFREQUENT_ACCESS"
+ tags = {
+ Name = "microvm-runtime-logs"
+ LogOnly = "runtime"
+ }
+ }
+ }
+}
+
+run "exposes_microvm_control_plane_contract" {
+ command = apply
+
+ assert {
+ condition = toset(keys(output.provider)) == toset(["environment_variables", "policies", "resources"])
+ error_message = "The MicroVM provider contract must expose only integration and resource data."
+ }
+
+ assert {
+ condition = (
+ output.provider.environment_variables.scale_up["MICROVM_CLUSTER"] == "runner-cluster"
+ && output.provider.environment_variables.scale_up["MICROVM_IMAGE_ARN"] == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ && output.provider.environment_variables.scale_up["MICROVM_IMAGE_VERSION"] == "3"
+ && output.provider.environment_variables.scale_up["MICROVM_EXECUTION_ROLE_ARN"] == "arn:aws:iam::123456789012:role/microvm-test-runner"
+ && jsondecode(output.provider.environment_variables.scale_up["MICROVM_INGRESS_NETWORK_CONNECTORS"])[0] == "arn:aws:lambda:eu-west-1:123456789012:network-connector:ingress"
+ && jsondecode(output.provider.environment_variables.scale_up["MICROVM_EGRESS_NETWORK_CONNECTORS"])[0] == "arn:aws:lambda:eu-west-1:123456789012:network-connector:egress"
+ && output.provider.environment_variables.scale_up["MICROVM_LOG_GROUP"] == "/github-self-hosted-runners/microvm-test/microvm"
+ && output.provider.environment_variables.scale_up["MICROVM_METADATA_SSM_PATH"] == "/github-action-runners/config/microvm-metadata"
+ && output.provider.environment_variables.scale_up["SSM_TOKEN_PATH"] == "/github-action-runners/tokens"
+ )
+ error_message = "The MicroVM provider must map every configured runtime input to the canonical Lambda environment contract."
+ }
+
+ assert {
+ condition = (
+ toset(keys(output.provider.environment_variables.scale_up)) == toset([
+ "MICROVM_CLUSTER",
+ "MICROVM_EGRESS_NETWORK_CONNECTORS",
+ "MICROVM_EXECUTION_ROLE_ARN",
+ "MICROVM_IMAGE_ARN",
+ "MICROVM_IMAGE_VERSION",
+ "MICROVM_INGRESS_NETWORK_CONNECTORS",
+ "MICROVM_LOG_GROUP",
+ "MICROVM_METADATA_SSM_PATH",
+ "SSM_TOKEN_PATH",
+ ])
+ && output.provider.environment_variables.scale_up == output.provider.environment_variables.scale_down
+ && output.provider.environment_variables.scale_up == output.provider.environment_variables.pool
+ && !contains(keys(output.provider.environment_variables.scale_up), "RUNNER_BOOT_TIME_IN_MINUTES")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_IMAGE_IDENTIFIER")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_MAXIMUM_DURATION_IN_SECONDS")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_RUN_CONFIG")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_TAGS")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_METADATA_TAGS")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_RUNNER_CONFIG_SSM_ARN")
+ )
+ error_message = "All three control-plane fragments must match the runtime key inventory and omit stale or webhook-owned keys."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_up.statement[0].actions == toset(["lambda:ListMicrovms", "lambda:PassNetworkConnector"])
+ && data.aws_iam_policy_document.scale_up.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_up.statement[1].actions == toset(["lambda:RunMicrovm", "lambda:TerminateMicrovm"])
+ && data.aws_iam_policy_document.scale_up.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"])
+ )
+ error_message = "Scale-up and pool must receive the MicroVM inventory, connector, launch, and cleanup permissions."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_up.statement[2].actions == toset(["ssm:AddTagsToResource", "ssm:DeleteParameter", "ssm:PutParameter"])
+ && data.aws_iam_policy_document.scale_up.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_up.statement[3].actions == toset(["ssm:GetParametersByPath"])
+ && data.aws_iam_policy_document.scale_up.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_up.statement[4].actions == toset(["ssm:GetParameters"])
+ && data.aws_iam_policy_document.scale_up.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ )
+ error_message = "Scale-up and pool must read the metadata hierarchy and exact JIT fence records while keeping metadata writes child-scoped."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_up.statement[5].actions == toset(["iam:PassRole"])
+ && data.aws_iam_policy_document.scale_up.statement[5].resources == toset(["arn:aws:iam::123456789012:role/microvm-test-runner"])
+ && length(data.aws_iam_policy_document.scale_up.statement[5].condition) == 0
+ && data.aws_iam_policy_document.scale_up.statement[6].actions == toset(["ssm:DeleteParameter"])
+ && data.aws_iam_policy_document.scale_up.statement[6].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ )
+ error_message = "Scale-up and pool must receive exact runner-role PassRole and lane-token cleanup grants."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_down.statement[0].actions == toset(["lambda:ListMicrovms"])
+ && data.aws_iam_policy_document.scale_down.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_down.statement[1].actions == toset(["lambda:TerminateMicrovm"])
+ && data.aws_iam_policy_document.scale_down.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"])
+ && data.aws_iam_policy_document.scale_down.statement[2].actions == toset(["ssm:DeleteParameter", "ssm:PutParameter"])
+ && data.aws_iam_policy_document.scale_down.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_down.statement[3].actions == toset(["ssm:GetParametersByPath"])
+ && data.aws_iam_policy_document.scale_down.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_down.statement[4].actions == toset(["ssm:DeleteParameter"])
+ && data.aws_iam_policy_document.scale_down.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ )
+ error_message = "Scale-down must receive lifecycle, metadata, and exact lane-token cleanup permissions."
+ }
+
+ assert {
+ condition = (
+ length(setintersection(toset(flatten(data.aws_iam_policy_document.scale_up.statement[*].actions)), toset(["lambda:ListTags", "lambda:TagResource", "lambda:UntagResource"]))) == 0
+ && length(setintersection(toset(flatten(data.aws_iam_policy_document.scale_down.statement[*].actions)), toset(["lambda:ListTags", "lambda:TagResource", "lambda:UntagResource"]))) == 0
+ )
+ error_message = "The MicroVM provider must not grant unsupported runtime tagging actions."
+ }
+
+ assert {
+ condition = (
+ toset(keys(output.provider.policies)) == toset(["runner", "scale_up", "scale_down", "pool"])
+ && toset(keys(output.provider.policies.runner.inline_policies)) == toset(["cloudwatch", "runner_metadata", "runtime_logs", "ssm_jit"])
+ && output.provider.policies.runner.inline_policies.cloudwatch.name == "runner-microvm-cloudwatch"
+ && output.provider.policies.runner.inline_policies.runner_metadata.name == "runner-microvm-metadata"
+ && output.provider.policies.runner.inline_policies.ssm_jit.name == "runner-microvm-ssm-jit"
+ && output.provider.policies.runner.inline_policies.runtime_logs.name == "runner-microvm-runtime-logs"
+ && output.provider.policies.runner.managed_policy_arns["readonly"] == "arn:aws:iam::aws:policy/ReadOnlyAccess"
+ && !output.provider.policies.scale_up.managed_policy_enabled
+ && !output.provider.policies.pool.managed_policy_enabled
+ )
+ error_message = "The MicroVM provider must return policy fragments grouped by common component."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.runner_ssm_jit.statement[0].actions == toset(["ssm:DeleteParameter", "ssm:GetParameter"])
+ && data.aws_iam_policy_document.runner_ssm_jit.statement[0].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].actions == toset(["ssm:GetParameter"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/enable_cloudwatch",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*.tags",
+ ])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[0].actions == toset(["ssm:GetParameter"])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[0].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/cloudwatch_agent_config_runner"])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[1].actions == toset(["logs:CreateLogStream", "logs:DescribeLogStreams", "logs:PutLogEvents"])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[1].resources == toset([
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/internal_service:*",
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/run:*",
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/runner:*",
+ ])
+ && length(data.aws_iam_policy_document.runner_runtime_logs.statement) == 1
+ && data.aws_iam_policy_document.runner_runtime_logs.statement[0].actions == toset(["logs:CreateLogStream", "logs:PutLogEvents"])
+ && data.aws_iam_policy_document.runner_runtime_logs.statement[0].resources == toset(["arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/microvm:*"])
+ )
+ error_message = "Managed MicroVM runners must receive exact lane configuration, metadata, JIT, native-runtime, and CloudWatch-agent permissions."
+ }
+
+ assert {
+ condition = (
+ toset(keys(output.provider.resources)) == toset(["execution_role_arn", "image_arn", "image_version", "logfiles", "runners_log_groups"])
+ && output.provider.resources.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ && output.provider.resources.image_version == "3"
+ && output.provider.resources.execution_role_arn == "arn:aws:iam::123456789012:role/microvm-test-runner"
+ && length(output.provider.resources.runners_log_groups) == 4
+ && output.provider.resources.runners_log_groups[0].name == "/github-self-hosted-runners/microvm-test/microvm"
+ && toset(slice(output.provider.resources.runners_log_groups[*].name, 1, 4)) == toset([
+ "/github-self-hosted-runners/microvm-test/internal_service",
+ "/github-self-hosted-runners/microvm-test/run",
+ "/github-self-hosted-runners/microvm-test/runner",
+ ])
+ && output.provider.resources.logfiles == local.logfiles
+ )
+ error_message = "The MicroVM provider must expose its selected image, execution role, native runtime group, and CloudWatch-agent resources."
+ }
+
+ assert {
+ condition = (
+ aws_cloudwatch_log_group.runtime.name == "/github-self-hosted-runners/microvm-test/microvm"
+ && aws_cloudwatch_log_group.runtime.retention_in_days == 30
+ && aws_cloudwatch_log_group.runtime.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/runtime-logs"
+ && aws_cloudwatch_log_group.runtime.log_group_class == "INFREQUENT_ACCESS"
+ && aws_cloudwatch_log_group.runtime.tags == tomap({
+ Name = "microvm-runtime-logs"
+ Module = "runner"
+ LogOnly = "runtime"
+ })
+ )
+ error_message = "The MicroVM provider must own its lane-scoped log group and apply the common observability lifecycle and tag scopes."
+ }
+
+ assert {
+ condition = (
+ aws_ssm_parameter.runner_enable_cloudwatch.name == "/github-action-runners/config/enable_cloudwatch"
+ && aws_ssm_parameter.runner_enable_cloudwatch.value == "true"
+ && length(aws_ssm_parameter.cloudwatch_agent_config_runner) == 1
+ && aws_ssm_parameter.cloudwatch_agent_config_runner[0].name == "/github-action-runners/config/cloudwatch_agent_config_runner"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Name"] == "parameter"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Module"] == "runner"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Ssm"] == "shared"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Parameter"] == "metadata"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Precedence"] == "parameter"
+ && aws_ssm_parameter.cloudwatch_agent_config_runner[0].tags == aws_ssm_parameter.runner_enable_cloudwatch.tags
+ )
+ error_message = "The MicroVM provider must publish the EC2-compatible CloudWatch enablement and agent-config parameters with standard SSM tag precedence."
+ }
+
+ assert {
+ condition = (
+ length(local.logfiles) == 3
+ && local.logfiles[0].file_path == "/var/log/microvm/internal-services.log"
+ && local.logfiles[0].log_group_name == "/github-self-hosted-runners/microvm-test/internal_service"
+ && local.logfiles[1].file_path == "/var/log/microvm/run.log"
+ && local.logfiles[1].log_group_name == "/github-self-hosted-runners/microvm-test/run"
+ && local.logfiles[2].file_path == "/opt/actions-runner/_diag/Runner_**.log"
+ && local.logfiles[2].log_group_name == "/github-self-hosted-runners/microvm-test/runner"
+ && alltrue([for log_file in local.logfiles : (
+ log_file.log_group_class == "STANDARD"
+ && log_file.log_stream_name == "{microvm_id}"
+ )])
+ && length(jsondecode(aws_ssm_parameter.cloudwatch_agent_config_runner[0].value).logs.logs_collected.files.collect_list) == 3
+ && toset(aws_cloudwatch_log_group.gh_runners[*].name) == toset(local.runner_log_group_names)
+ && alltrue([for log_group in aws_cloudwatch_log_group.gh_runners : (
+ log_group.retention_in_days == 30
+ && log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/runtime-logs"
+ && log_group.tags == aws_cloudwatch_log_group.runtime.tags
+ )])
+ )
+ error_message = "The default MicroVM agent configuration must route internal-service, run, and runner files to separately managed log groups."
+ }
+}
+
+run "normalizes_ssm_paths_and_arns" {
+ command = plan
+
+ variables {
+ ssm = {
+ paths = {
+ root = "/github-action-runners/"
+ tokens = "/tokens/"
+ config = "/config/"
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ output.provider.environment_variables.scale_up["MICROVM_METADATA_SSM_PATH"] == "/github-action-runners/config/microvm-metadata"
+ && output.provider.environment_variables.scale_up["SSM_TOKEN_PATH"] == "/github-action-runners/tokens"
+ && data.aws_iam_policy_document.scale_up.statement[6].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.scale_down.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.runner_ssm_jit.statement[0].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/enable_cloudwatch",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*.tags",
+ ])
+ )
+ error_message = "The MicroVM provider must normalize SSM path segments before exposing hook values or IAM resources."
+ }
+}
+
+run "accepts_external_runner_role_and_policy_overrides" {
+ command = plan
+
+ variables {
+ runner = {
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/external-microvm-runner"
+ name = "external-microvm-runner"
+ managed = false
+ }
+ }
+ }
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-override"
+ iam = {
+ resource_arns = {
+ images = ["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-*"]
+ }
+ additional_policy_json = {
+ scale_up = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ managed_policies = {
+ scale_up = {
+ arn = "arn:aws:iam::123456789012:policy/microvm-scale-up"
+ }
+ pool = {
+ arn = "arn:aws:iam::123456789012:policy/microvm-pool"
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ output.provider.environment_variables.scale_up["MICROVM_EXECUTION_ROLE_ARN"] == "arn:aws:iam::123456789012:role/external-microvm-runner"
+ && output.provider.environment_variables.scale_up["MICROVM_INGRESS_NETWORK_CONNECTORS"] == ""
+ && output.provider.environment_variables.scale_up["MICROVM_EGRESS_NETWORK_CONNECTORS"] == ""
+ && output.provider.environment_variables.scale_up["MICROVM_LOG_GROUP"] == "/github-self-hosted-runners/microvm-test/microvm"
+ && data.aws_iam_policy_document.scale_up.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_up.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-*"])
+ && data.aws_iam_policy_document.scale_up.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_up.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_up.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_up.statement[5].resources == toset(["arn:aws:iam::123456789012:role/external-microvm-runner"])
+ && data.aws_iam_policy_document.scale_up.statement[6].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.scale_down.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_down.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-*"])
+ && data.aws_iam_policy_document.scale_down.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_down.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_down.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ )
+ error_message = "The provider-neutral external runner role and image allowlist must reach their scoped statements without narrowing required list, connector, or metadata permissions."
+ }
+
+ assert {
+ condition = (
+ output.provider.policies.scale_up.additional_iam_policy_json == "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ && output.provider.policies.scale_up.managed_policy_enabled
+ && output.provider.policies.scale_up.managed_policy_arn == "arn:aws:iam::123456789012:policy/microvm-scale-up"
+ && output.provider.policies.pool.managed_policy_enabled
+ && output.provider.policies.pool.managed_policy_arn == "arn:aws:iam::123456789012:policy/microvm-pool"
+ )
+ error_message = "Optional MicroVM policy attachments must stay controlled by wrapper presence."
+ }
+
+
+ assert {
+ condition = (
+ toset(keys(output.provider.policies.runner.inline_policies)) == toset(["cloudwatch", "runner_metadata", "runtime_logs", "ssm_jit"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/enable_cloudwatch",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*.tags",
+ ])
+ && length(data.aws_iam_policy_document.runner_runtime_logs.statement) == 1
+ && data.aws_iam_policy_document.runner_runtime_logs.statement[0].resources == toset(["arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/microvm:*"])
+ )
+ error_message = "The provider contract must keep plan-known runner-policy keys and scope runtime logging to its provider-managed group."
+ }
+}
+
+run "disables_cloudwatch_agent_without_disabling_native_runtime_logging" {
+ command = apply
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ cloudwatch_agent = {
+ enabled = false
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ tostring(aws_ssm_parameter.runner_enable_cloudwatch.value) == "false"
+ && length(aws_ssm_parameter.cloudwatch_agent_config_runner) == 0
+ && length(aws_cloudwatch_log_group.gh_runners) == 0
+ && length(local.logfiles) == 0
+ && !contains(keys(output.provider.policies.runner.inline_policies), "cloudwatch")
+ && length(output.provider.resources.runners_log_groups) == 1
+ && output.provider.resources.runners_log_groups[0].name == aws_cloudwatch_log_group.runtime.name
+ && output.provider.environment_variables.scale_up["MICROVM_LOG_GROUP"] == aws_cloudwatch_log_group.runtime.name
+ )
+ error_message = "Disabling the image CloudWatch agent must retain the explicit false flag and native RunMicrovm logging while removing only agent-owned resources and permissions."
+ }
+}
+
+run "accepts_custom_cloudwatch_agent_config_and_log_group" {
+ command = apply
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ cloudwatch_agent = {
+ enabled = true
+ config = "{\"agent\":{\"region\":\"eu-west-1\"}}"
+ }
+ log_files = [{
+ log_group_name = "custom-microvm"
+ prefix_log_group = false
+ file_path = "/var/log/custom.log"
+ log_stream_name = "{microvm_id}/custom"
+ log_class = "INFREQUENT_ACCESS"
+ }]
+ }
+ }
+
+ assert {
+ condition = (
+ aws_ssm_parameter.cloudwatch_agent_config_runner[0].value == "{\"agent\":{\"region\":\"eu-west-1\"}}"
+ && length(local.logfiles) == 1
+ && local.logfiles[0].file_path == "/var/log/custom.log"
+ && local.logfiles[0].log_group_class == "INFREQUENT_ACCESS"
+ && local.logfiles[0].log_group_name == "/custom-microvm"
+ && local.logfiles[0].log_stream_name == "{microvm_id}/custom"
+ && aws_cloudwatch_log_group.gh_runners[0].name == "/custom-microvm"
+ && aws_cloudwatch_log_group.gh_runners[0].log_group_class == "INFREQUENT_ACCESS"
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[1].resources == toset([
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/custom-microvm:*",
+ ])
+ )
+ error_message = "Custom MicroVM agent configuration and log-file routing must replace the generated defaults without widening IAM."
+ }
+}
+
+run "rejects_invalid_image_arn" {
+ command = plan
+
+ variables {
+ config = {
+ image_arn = "not-a-microvm-image-arn"
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_metadata_path_overlapping_jit_path" {
+ command = plan
+
+ variables {
+ ssm = {
+ paths = {
+ root = "/github-action-runners"
+ tokens = "config/microvm-metadata"
+ config = "config"
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_invalid_metadata_path" {
+ command = plan
+
+ variables {
+ ssm = {
+ paths = {
+ root = "/github-action-runners"
+ tokens = "tokens"
+ config = "invalid config"
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_invalid_image_resource_allowlist" {
+ command = plan
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ iam = {
+ resource_arns = {
+ images = []
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_invalid_network_connector" {
+ command = plan
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ ingress_network_connectors = [
+ " ",
+ ]
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_more_than_ten_network_connectors" {
+ command = plan
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ egress_network_connectors = [
+ for index in range(11) :
+ "arn:aws:lambda:eu-west-1:123456789012:network-connector:egress-${index}"
+ ]
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_unsupported_runner_architecture" {
+ command = plan
+
+ variables {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/microvm-test-runner"
+ name = "microvm-test-runner"
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_runner]
+}
+
+run "rejects_unsupported_runner_os" {
+ command = plan
+
+ variables {
+ runner = {
+ os = "windows"
+ architecture = "arm64"
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/microvm-test-runner"
+ name = "microvm-test-runner"
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_runner]
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/README.md b/modules/compute-providers/aws/microvm/trust-policy/README.md
new file mode 100644
index 0000000000..43302d1055
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/README.md
@@ -0,0 +1,41 @@
+# AWS Lambda MicroVM runner trust policy
+
+This internal submodule builds the MicroVM runner-role trust policy independently from the runtime module that consumes the role. It preserves the default Lambda service trust and optionally merges an additional IAM trust policy document supplied by the common runner configuration.
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
+| [aws](#requirement\_aws) | >= 6.33 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.33 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_iam_policy_document.assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the MicroVM provider's default runner-role trust policy. | `string` | `null` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [assume\_role\_policy](#output\_assume\_role\_policy) | MicroVM runner-role trust policy including any additional trust statements. |
+
diff --git a/modules/compute-providers/aws/microvm/trust-policy/assume-role.tf b/modules/compute-providers/aws/microvm/trust-policy/assume-role.tf
new file mode 100644
index 0000000000..3654bce8bf
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/assume-role.tf
@@ -0,0 +1,21 @@
+data "aws_iam_policy_document" "default" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "sts:AssumeRole",
+ "sts:TagSession",
+ ]
+
+ principals {
+ type = "Service"
+ identifiers = ["lambda.amazonaws.com"]
+ }
+ }
+}
+
+data "aws_iam_policy_document" "assume_role" {
+ source_policy_documents = compact([
+ data.aws_iam_policy_document.default.json,
+ var.additional_trust_policy_json,
+ ])
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/outputs.tf b/modules/compute-providers/aws/microvm/trust-policy/outputs.tf
new file mode 100644
index 0000000000..8564675873
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/outputs.tf
@@ -0,0 +1,4 @@
+output "assume_role_policy" {
+ description = "MicroVM runner-role trust policy including any additional trust statements."
+ value = data.aws_iam_policy_document.assume_role.json
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/tests/trust-policy.tftest.hcl b/modules/compute-providers/aws/microvm/trust-policy/tests/trust-policy.tftest.hcl
new file mode 100644
index 0000000000..5f0173ebc1
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/tests/trust-policy.tftest.hcl
@@ -0,0 +1,59 @@
+mock_provider "aws" {
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{}"
+ }
+ }
+}
+
+run "returns_default_microvm_trust_policy" {
+ command = plan
+
+ assert {
+ condition = toset(data.aws_iam_policy_document.default.statement[0].actions) == toset(["sts:AssumeRole", "sts:TagSession"])
+ error_message = "The MicroVM runner role must allow assume-role and tagged sessions."
+ }
+
+ assert {
+ condition = anytrue([
+ for principal in data.aws_iam_policy_document.default.statement[0].principals :
+ principal.type == "Service" && toset(principal.identifiers) == toset(["lambda.amazonaws.com"])
+ ])
+ error_message = "The MicroVM runner role must trust the Lambda service principal required by the provider."
+ }
+
+ assert {
+ condition = (
+ length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 1
+ && output.assume_role_policy == data.aws_iam_policy_document.assume_role.json
+ )
+ error_message = "The MicroVM trust-policy module must return the default trust document as assume_role_policy."
+ }
+}
+
+run "merges_additional_trust_policy" {
+ command = plan
+
+ variables {
+ additional_trust_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"TrustDeploymentRole\",\"Effect\":\"Allow\",\"Action\":\"sts:AssumeRole\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:role/deployer\"}}]}"
+ }
+
+ assert {
+ condition = (
+ length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 2
+ && contains(data.aws_iam_policy_document.assume_role.source_policy_documents, var.additional_trust_policy_json)
+ && output.assume_role_policy == data.aws_iam_policy_document.assume_role.json
+ )
+ error_message = "The MicroVM trust-policy module must merge and return the additional trust policy document."
+ }
+}
+
+run "rejects_invalid_additional_trust_policy" {
+ command = plan
+
+ variables {
+ additional_trust_policy_json = "{"
+ }
+
+ expect_failures = [var.additional_trust_policy_json]
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/variables.tf b/modules/compute-providers/aws/microvm/trust-policy/variables.tf
new file mode 100644
index 0000000000..1af67309d2
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/variables.tf
@@ -0,0 +1,10 @@
+variable "additional_trust_policy_json" {
+ description = "Optional IAM policy document merged with the MicroVM provider's default runner-role trust policy."
+ type = string
+ default = null
+
+ validation {
+ condition = var.additional_trust_policy_json == null ? true : can(jsondecode(var.additional_trust_policy_json))
+ error_message = "additional_trust_policy_json must be valid JSON when set."
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/versions.tf b/modules/compute-providers/aws/microvm/trust-policy/versions.tf
new file mode 100644
index 0000000000..3ef011ea0a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.4.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.33"
+ }
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/validations.tf b/modules/compute-providers/aws/microvm/validations.tf
new file mode 100644
index 0000000000..75c2aeb33b
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/validations.tf
@@ -0,0 +1,80 @@
+resource "terraform_data" "validate_config" {
+ lifecycle {
+ precondition {
+ condition = can(regex("^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$", var.config.image_arn))
+ error_message = "compute_provider.aws.microvm.image_arn must be a Lambda MicroVM image ARN."
+ }
+
+ precondition {
+ condition = var.config.iam.resource_arns.images == null ? true : (
+ length(var.config.iam.resource_arns.images) > 0 &&
+ alltrue([
+ for image_arn in var.config.iam.resource_arns.images :
+ image_arn == "*" || can(regex("^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$", image_arn))
+ ])
+ )
+ error_message = "compute_provider.aws.microvm.iam.resource_arns.images must be null or a non-empty list containing only * or Lambda MicroVM image ARN patterns."
+ }
+
+ precondition {
+ condition = (
+ length(var.config.ingress_network_connectors) <= 10 &&
+ alltrue([
+ for connector in var.config.ingress_network_connectors :
+ can(regex("^arn:[^:]+:lambda:[^:]+:([0-9]{12}|aws):network-connector:[^[:space:]]+$", connector))
+ ])
+ )
+ error_message = "compute_provider.aws.microvm.ingress_network_connectors must contain at most 10 Lambda network-connector ARNs."
+ }
+
+ precondition {
+ condition = (
+ length(var.config.egress_network_connectors) <= 10 &&
+ alltrue([
+ for connector in var.config.egress_network_connectors :
+ can(regex("^arn:[^:]+:lambda:[^:]+:([0-9]{12}|aws):network-connector:[^[:space:]]+$", connector))
+ ])
+ )
+ error_message = "compute_provider.aws.microvm.egress_network_connectors must contain at most 10 Lambda network-connector ARNs."
+ }
+
+ precondition {
+ condition = try(var.config.iam.additional_policy_json.scale_up, null) == null ? true : can(jsondecode(var.config.iam.additional_policy_json.scale_up))
+ error_message = "compute_provider.aws.microvm.iam.additional_policy_json.scale_up must be valid JSON when set."
+ }
+
+ precondition {
+ condition = !(
+ local.microvm_metadata_ssm_path == local.runner_jit_ssm_path ||
+ startswith(local.microvm_metadata_ssm_path, "${local.runner_jit_ssm_path}/") ||
+ startswith(local.runner_jit_ssm_path, "${local.microvm_metadata_ssm_path}/")
+ )
+ error_message = "The MicroVM metadata Parameter Store path must be separate from the runner JIT configuration path."
+ }
+
+ precondition {
+ condition = (
+ startswith(var.ssm.paths.root, "/") &&
+ trim(var.ssm.paths.root, "/") != "" &&
+ trim(var.ssm.paths.config, "/") != "" &&
+ can(regex("^/[A-Za-z0-9_./-]+$", local.microvm_metadata_ssm_path)) &&
+ !strcontains(local.microvm_metadata_ssm_path, "//")
+ )
+ error_message = "The derived MicroVM metadata Parameter Store path must be an absolute path containing only letters, numbers, dot, underscore, hyphen, and slash."
+ }
+ }
+}
+
+resource "terraform_data" "validate_runner" {
+ lifecycle {
+ precondition {
+ condition = var.runner.os == "linux" && var.runner.architecture == "arm64"
+ error_message = "Lambda MicroVM runners require runner.os = linux and runner.architecture = arm64."
+ }
+
+ precondition {
+ condition = length(var.runner.name_prefix) <= 45
+ error_message = "runner.name_prefix must be at most 45 characters."
+ }
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/variables.tf b/modules/compute-providers/aws/microvm/variables.tf
new file mode 100644
index 0000000000..388b06135a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/variables.tf
@@ -0,0 +1,195 @@
+# tflint-ignore: terraform_unused_declarations
+variable "aws_partition" {
+ description = "AWS partition used to construct IAM ARNs."
+ type = string
+ default = "aws"
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "aws_region" {
+ description = "AWS region used by compute-provider resources and policy documents."
+ type = string
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "prefix" {
+ description = "Prefix used to identify resources created for the runner configuration."
+ type = string
+ default = "github-actions"
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "tags" {
+ description = "Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes."
+ type = map(string)
+ default = {}
+}
+
+variable "config" {
+ description = <<-EOT
+ Lambda MicroVM compute-provider configuration. Paths match `compute_provider.aws.microvm` in runner-config.
+
+ - `image_arn`: ARN of the MicroVM image used to run GitHub runners.
+ - `image_version`: Optional MicroVM image version.
+ - `ingress_network_connectors`: Up to 10 Lambda network-connector ARNs passed to RunMicrovm.
+ - `egress_network_connectors`: Up to 10 Lambda network-connector ARNs passed to RunMicrovm.
+ - `cloudwatch_agent.enabled`: Enables the image CloudWatch agent through the shared runner configuration path.
+ - `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`. Custom log destinations must also be declared in `log_files` so Terraform creates their groups and IAM permissions.
+ - `log_files`: Optional files collected by the CloudWatch agent. Null uses the MicroVM defaults.
+ - `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
+ - `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
+ - `log_files[].file_path`: File or glob read by the CloudWatch agent.
+ - `log_files[].log_stream_name`: Log-stream template. The image replaces `{microvm_id}` with the current MicroVM identifier.
+ - `log_files[].log_class`: CloudWatch log-group class for the collected file.
+ - `environment_variables`: Additional provider-specific Lambda environment variables merged into scale-up, scale-down, and pool.
+ - `iam.resource_arns.images`: Optional MicroVM image ARN allowlist for RunMicrovm and TerminateMicrovm. Null restricts both actions to `image_arn`; set an explicit list when dynamic image overrides are enabled. Provider-required list and connector permissions remain separately scoped to `*`.
+ - `iam.additional_policy_json.scale_up`: Optional additional provider policy attached separately to the scale-up Lambda role.
+ - `iam.managed_policies.scale_up`: Optional managed-policy wrapper attached to the scale-up Lambda role. Wrapper presence controls resource creation during planning.
+ - `iam.managed_policies.scale_up.arn`: ARN of the scale-up managed policy. The ARN may remain unknown until apply.
+ - `iam.managed_policies.pool`: Optional managed-policy wrapper attached to the pool Lambda role. Wrapper presence controls resource creation during planning.
+ - `iam.managed_policies.pool.arn`: ARN of the pool managed policy. The ARN may remain unknown until apply.
+ EOT
+
+ type = object({
+ image_arn = string
+ image_version = optional(string, null)
+ ingress_network_connectors = optional(list(string), [])
+ egress_network_connectors = optional(list(string), [])
+ cloudwatch_agent = optional(object({
+ enabled = optional(bool, true)
+ config = optional(string, null)
+ }), {})
+ 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)
+ environment_variables = optional(map(string), {})
+ iam = optional(object({
+ resource_arns = optional(object({
+ images = optional(list(string), null)
+ }), {})
+ additional_policy_json = optional(object({
+ scale_up = optional(string, null)
+ }), {})
+ managed_policies = optional(object({
+ scale_up = optional(object({
+ arn = string
+ }), null)
+ pool = optional(object({
+ arn = string
+ }), null)
+ }), {})
+ }), {})
+ })
+
+ nullable = false
+}
+
+variable "runner" {
+ description = <<-EOT
+ Resolved runner settings consumed by the Lambda MicroVM compute provider.
+
+ - `os`: Runner operating system. Lambda MicroVM requires `linux`.
+ - `architecture`: Runner distribution architecture. Lambda MicroVM requires `arm64`.
+ - `name_prefix`: Prefix added to registered runner names.
+ - `run_as_root`: Runs the runner service as root.
+ - `run_as`: Operating-system user used when `run_as_root` is false.
+ - `hooks.job_started`: Script installed as the runner job-started hook.
+ - `hooks.job_completed`: Script installed as the runner job-completed hook.
+ - `iam.role.arn`: Resolved runner-role ARN used as the MicroVM execution role and referenced by provider policies.
+ - `iam.role.name`: Resolved runner-role name used by provider resources.
+ - `iam.role.managed`: Whether runner-config manages the resolved runner role. Callers own an external role and must grant it `ssm:GetParameter` on the lane's `microvm-metadata/*.tags` and `enable_cloudwatch` parameters, `ssm:GetParameter` and `ssm:DeleteParameter` on the lane token path, plus `logs:CreateLogStream` and `logs:PutLogEvents` on the provider-managed runtime log group. When the CloudWatch agent is enabled, it also needs `ssm:GetParameter` on `cloudwatch_agent_config_runner` and stream access to the configured runner log groups.
+ - `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
+ - `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`.
+ EOT
+ type = object({
+ os = optional(string, "linux")
+ architecture = optional(string, "arm64")
+ name_prefix = optional(string, "")
+ run_as_root = optional(bool, false)
+ run_as = optional(string, "ec2-user")
+ hooks = optional(object({
+ job_started = optional(string, "")
+ job_completed = optional(string, "")
+ }), {})
+ iam = object({
+ role = object({
+ arn = string
+ name = string
+ managed = optional(bool, true)
+ })
+ managed_policy_arns = optional(map(string), {})
+ path = optional(string, null)
+ })
+ })
+
+ nullable = false
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "github" {
+ description = <<-EOT
+ GitHub Enterprise Server settings available to compute-provider bootstrap data.
+
+ - `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
+ - `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server.
+ EOT
+ type = object({
+ enterprise_server = optional(object({
+ url = optional(string, null)
+ ssl_verify = optional(bool, true)
+ }), {})
+ })
+ default = {}
+ nullable = false
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "ssm" {
+ description = <<-EOT
+ Parameter Store paths and tag scopes available to compute-provider bootstrap resources.
+
+ - `paths.root`: Root Parameter Store path for the runner configuration.
+ - `paths.tokens`: Path segment used for registration tokens and just-in-time configuration.
+ - `paths.config`: Path segment used for persistent runner and provider configuration. MicroVM control-plane metadata is stored under its `microvm-metadata` child prefix.
+ - `tags`: Shared SSM tags that override module-level `tags`.
+ - `parameters.tags`: Parameter-specific tags that override module-level and shared SSM tags.
+ EOT
+ type = object({
+ paths = object({
+ root = string
+ tokens = string
+ config = string
+ })
+ tags = optional(map(string), {})
+ parameters = optional(object({
+ tags = optional(map(string), {})
+ }), {})
+ })
+
+ nullable = false
+}
+
+variable "observability" {
+ description = <<-EOT
+ Provider-neutral observability settings applied to the provider-managed MicroVM runtime log group.
+
+ - `logs.retention_in_days`: CloudWatch Logs retention period.
+ - `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt the log group.
+ - `logs.class`: CloudWatch log-group class.
+ - `logs.tags`: Tags merged after module-level tags on the log group.
+ EOT
+ type = object({
+ logs = optional(object({
+ retention_in_days = optional(number, 180)
+ kms_key_id = optional(string, null)
+ class = optional(string, "STANDARD")
+ tags = optional(map(string), {})
+ }), {})
+ })
+ default = {}
+ nullable = false
+}
diff --git a/modules/compute-providers/aws/microvm/versions.tf b/modules/compute-providers/aws/microvm/versions.tf
new file mode 100644
index 0000000000..3ef011ea0a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.4.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.33"
+ }
+ }
+}
diff --git a/modules/multi-runner/.terraform.lock.hcl b/modules/multi-runner/.terraform.lock.hcl
new file mode 100644
index 0000000000..9559f5fbd8
--- /dev/null
+++ b/modules/multi-runner/.terraform.lock.hcl
@@ -0,0 +1,71 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "6.63.0"
+ constraints = ">= 6.21.0, >= 6.33.0"
+ hashes = [
+ "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=",
+ "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=",
+ "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611",
+ "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d",
+ "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a",
+ "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1",
+ "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d",
+ "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c",
+ "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb",
+ "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9",
+ "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd",
+ "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb",
+ "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925",
+ "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04",
+ "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54",
+ "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/null" {
+ version = "3.3.1"
+ constraints = "~> 3.0, ~> 3.2"
+ hashes = [
+ "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=",
+ "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=",
+ "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44",
+ "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451",
+ "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05",
+ "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3",
+ "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7",
+ "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1",
+ "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be",
+ "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891",
+ "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5",
+ "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610",
+ "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+ version = "3.9.0"
+ constraints = "~> 3.0"
+ hashes = [
+ "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=",
+ "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=",
+ "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1",
+ "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea",
+ "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f",
+ "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0",
+ "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61",
+ "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc",
+ "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e",
+ "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef",
+ "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b",
+ "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257",
+ "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04",
+ ]
+}
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 61a558389f..dcba871480 100644
--- a/modules/multi-runner/README.md
+++ b/modules/multi-runner/README.md
@@ -4,6 +4,8 @@
This module creates many runners with one or more GitHub Apps. The module utilizes the internal modules and deploys parts of the stack for each runner defined.
+Terraform 1.4 or later is required. Terraform 1.3 and earlier are no longer supported by this module.
+
### GitHub App round-robin
To distribute GitHub API rate limit usage, this module supports configuring multiple GitHub Apps via the `additional_github_apps` variable. The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly select an app for each API call, spreading the load across all configured apps.
@@ -18,8 +20,6 @@ The **webhook lambda** does not participate in round-robin: it only validates in
The module takes a configuration as input containing a matcher for the labels. The [webhook](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/webhook/) lambda is using the configuration to delegate events based on the labels in the workflow job and sent them to a dedicated queue based on the configuration. Events on each queue are processed by a dedicated lambda per configuration to scale runners.
-> **Experimental v2 configuration:** Set `experimental_features = ["multi-runner-v2"]` before using the provider-boundary inputs. Their schema may change during the experimental window; the acknowledgement flag will become a deprecated no-op for one release when the feature graduates.
-
For each configuration:
- When enabled, the [distribution syncer](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/runner-binaries-syncer/) is deployed for each unique combination of OS and architecture.
@@ -96,48 +96,12 @@ module "multi-runner" {
}
```
-### Provider-boundary v2 configuration
-
-The v2 form of `multi_runner_config` keeps runner, Lambda, orchestration, SSM, observability, and compute-provider settings under one configuration entry. The webhook matcher is configured under `orchestration_provider.webhook.matcherConfig`.
-
-```hcl
-multi_runner_config = {
- "linux-x64" = {
- tags = {
- Environment = "production"
- }
-
- runner = {
- os = "linux"
- architecture = "x64"
- extra_labels = ["large"]
- }
-
- orchestration_provider = {
- webhook = {
- matcherConfig = {
- labelMatchers = [["self-hosted", "linux", "x64", "large"]]
- }
- }
- }
-
- compute_provider = {
- aws = {
- ec2 = {
- instance_types = ["m5.large"]
- }
- }
- }
- }
-}
-```
-
## Requirements
| Name | Version |
|------|---------|
-| [terraform](#requirement\_terraform) | >= 1.3 |
+| [terraform](#requirement\_terraform) | >= 1.4 |
| [aws](#requirement\_aws) | >= 6.33 |
| [random](#requirement\_random) | ~> 3.0 |
@@ -145,8 +109,9 @@ multi_runner_config = {
| Name | Version |
|------|---------|
-| [aws](#provider\_aws) | >= 6.33 |
-| [random](#provider\_random) | ~> 3.0 |
+| [aws](#provider\_aws) | 6.63.0 |
+| [random](#provider\_random) | 3.9.0 |
+| [terraform](#provider\_terraform) | n/a |
## Modules
@@ -155,6 +120,7 @@ multi_runner_config = {
| [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a |
| [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a |
| [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a |
+| [runner\_configs](#module\_runner\_configs) | ../runner-config | n/a |
| [runners](#module\_runners) | ../runners | n/a |
| [ssm](#module\_ssm) | ../ssm | n/a |
| [webhook](#module\_webhook) | ../webhook | n/a |
@@ -168,6 +134,8 @@ multi_runner_config = {
| [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource |
| [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource |
| [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource |
+| [terraform_data.validate_v1](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [terraform_data.validate_v2](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
## Inputs
@@ -192,7 +160,7 @@ multi_runner_config = {
| [experimental\_features](#input\_experimental\_features) | Explicit acknowledgement for opt-in features whose schemas may changeobject({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}) | n/a | yes |
+| [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.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
}))
}) | `{}` | no |
| [global\_config](#input\_global\_config) | Global defaults shared by all runner lanes.object({
tags = optional(map(string), {})
roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})
}) | `{}` | no |
| [global\_config\_compute\_provider](#input\_global\_config\_compute\_provider) | Global compute-provider configuration shared by all runner lanes.object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
runner_deregistration = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_handler = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_notification_watcher = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}) | `{}` | no |
| [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.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 |
@@ -220,7 +188,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)
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)
}), {})
}), {})
})) | `{}` | no |
| [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no |
| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no |
| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no |
@@ -248,13 +216,13 @@ multi_runner_config = {
| [scale\_up\_lambda\_memory\_size](#input\_scale\_up\_lambda\_memory\_size) | Memory size limit in MB for scale\_up lambda. | `number` | `512` | no |
| [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. | object({
root = optional(string, "github-action-runners")
app = optional(string, "app")
runners = optional(string, "runners")
webhook = optional(string, "webhook")
}) | `{}` | no |
| [state\_event\_rule\_binaries\_syncer](#input\_state\_event\_rule\_binaries\_syncer) | Option to disable EventBridge Lambda trigger for the binary syncer, useful to stop automatic updates of binary distribution | `string` | `"ENABLED"` | no |
-| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes |
+| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which stable v1 action runners will be launched. Omit when using the experimental v2 interface. | `list(string)` | `null` | no |
| [syncer\_lambda\_s3\_key](#input\_syncer\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |
| [syncer\_lambda\_s3\_object\_version](#input\_syncer\_lambda\_s3\_object\_version) | S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no |
| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no |
| [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. | object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}) | `{}` | no |
| [user\_agent](#input\_user\_agent) | User agent used for API calls by lambda functions. | `string` | `"github-aws-runners"` | no |
-| [vpc\_id](#input\_vpc\_id) | The VPC for security groups of the action runners. | `string` | n/a | yes |
+| [vpc\_id](#input\_vpc\_id) | The VPC for security groups of stable v1 action runners. Omit when using the experimental v2 interface. | `string` | `null` | no |
| [webhook\_lambda\_apigateway\_access\_log\_settings](#input\_webhook\_lambda\_apigateway\_access\_log\_settings) | Access log settings for webhook API gateway. | object({
destination_arn = string
format = string
}) | `null` | no |
| [webhook\_lambda\_memory\_size](#input\_webhook\_lambda\_memory\_size) | Memory size limit in MB for webhook lambda. | `number` | `256` | no |
| [webhook\_lambda\_s3\_key](#input\_webhook\_lambda\_s3\_key) | S3 key for webhook lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |
@@ -270,6 +238,7 @@ multi_runner_config = {
| [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a |
| [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a |
| [runners\_map](#output\_runners\_map) | n/a |
+| [runners\_map\_v2](#output\_runners\_map\_v2) | n/a |
| [ssm\_parameters](#output\_ssm\_parameters) | n/a |
| [webhook](#output\_webhook) | n/a |
diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf
index 7c4a9807d1..4c7d4a6cd3 100644
--- a/modules/multi-runner/outputs.tf
+++ b/modules/multi-runner/outputs.tf
@@ -21,6 +21,18 @@ output "runners_map" {
}
}
+output "runners_map_v2" {
+ value = { for runner_key, runner in module.runner_configs : runner_key => {
+ runner = runner.runner
+ orchestration_provider = runner.orchestration_provider
+ scale_up = runner.scale_up
+ scale_down = runner.scale_down
+ pool = runner.pool
+ provider = runner.provider
+ }
+ }
+}
+
output "binaries_syncer_map" {
value = { for runner_binary_key, runner_binary in module.runner_binaries : runner_binary_key => {
lambda = runner_binary.lambda
diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf
new file mode 100644
index 0000000000..4e8652aa00
--- /dev/null
+++ b/modules/multi-runner/runners.experimental.tf
@@ -0,0 +1,38 @@
+module "runner_configs" {
+ source = "../runner-config"
+ for_each = {
+ for runner_key, runner_config in local.effective_config.multi_runner_config :
+ runner_key => runner_config if local.use_v2_config
+ }
+
+ aws_region = var.aws_region
+ aws_partition = var.aws_partition
+ prefix = "${var.prefix}-${each.key}"
+
+ tags = merge(
+ each.value.tags,
+ { "ghr:environment" = var.prefix },
+ )
+ runner = each.value.runner
+ github = merge(each.value.github, {
+ app_parameters = local.github_app_parameters
+ })
+ lambda = each.value.lambda
+ orchestration_provider = {
+ webhook = each.value.orchestration_provider.webhook == null ? null : {
+ runner = each.value.orchestration_provider.webhook.runner
+ github = each.value.orchestration_provider.webhook.github
+ queue = merge(each.value.orchestration_provider.webhook.queue, {
+ build = {
+ arn = aws_sqs_queue.queued_builds[each.key].arn
+ url = aws_sqs_queue.queued_builds[each.key].url
+ }
+ })
+ lambda = each.value.orchestration_provider.webhook.lambda
+ job_retry = each.value.orchestration_provider.webhook.job_retry
+ }
+ }
+ ssm = each.value.ssm
+ observability = each.value.observability
+ compute_provider = each.value.compute_provider
+}
diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf
index 61c5f57583..5e0bc44022 100644
--- a/modules/multi-runner/runners.tf
+++ b/modules/multi-runner/runners.tf
@@ -1,6 +1,9 @@
module "runners" {
- source = "../runners"
- for_each = local.effective_config.multi_runner_config
+ source = "../runners"
+ for_each = {
+ for runner_key, runner_config in local.effective_config.multi_runner_config :
+ runner_key => runner_config if !local.use_v2_config
+ }
aws_region = var.aws_region
aws_partition = var.aws_partition
vpc_id = each.value.compute_provider.aws.ec2.vpc_id
diff --git a/modules/multi-runner/tests/config-resolution.tftest.hcl b/modules/multi-runner/tests/config-resolution.tftest.hcl
index efb117ed7f..107a5a73e8 100644
--- a/modules/multi-runner/tests/config-resolution.tftest.hcl
+++ b/modules/multi-runner/tests/config-resolution.tftest.hcl
@@ -59,30 +59,9 @@ mock_provider "random" {}
mock_provider "null" {}
variables {
- aws_region = "eu-west-1"
- vpc_id = "vpc-stable"
- subnet_ids = ["subnet-stable"]
-
- github_app = {
- key_base64_ssm = {
- arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/key"
- name = "/tests/github-app/key"
- }
- id_ssm = {
- arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/id"
- name = "/tests/github-app/id"
- }
- webhook_secret_ssm = {
- arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/webhook-secret"
- name = "/tests/github-app/webhook-secret"
- }
- }
-
- lambda_s3_bucket = "test-lambda-artifacts"
- runners_lambda_zip = "README.md"
- runners_lambda_s3_key = "runners.zip"
- webhook_lambda_s3_key = "webhook.zip"
- syncer_lambda_s3_key = "runner-binaries-syncer.zip"
+ aws_region = "eu-west-1"
+ prefix = "test"
+ aws_partition = "aws"
global_config_github = {
app = {
@@ -152,6 +131,30 @@ run "v1_stable_inputs_translate_into_effective_base" {
command = plan
variables {
+ vpc_id = "vpc-stable"
+ subnet_ids = ["subnet-stable"]
+
+ github_app = {
+ key_base64_ssm = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/key"
+ name = "/tests/github-app/key"
+ }
+ id_ssm = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/id"
+ name = "/tests/github-app/id"
+ }
+ webhook_secret_ssm = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/webhook-secret"
+ name = "/tests/github-app/webhook-secret"
+ }
+ }
+
+ lambda_s3_bucket = "test-lambda-artifacts"
+ runners_lambda_zip = "README.md"
+ runners_lambda_s3_key = "runners.zip"
+ webhook_lambda_s3_key = "webhook.zip"
+ syncer_lambda_s3_key = "runner-binaries-syncer.zip"
+
tags = {
source = "v1"
}
@@ -193,14 +196,22 @@ run "v1_stable_inputs_translate_into_effective_base" {
)
error_message = "Stable v1 inputs must translate into the effective experimental base without leaking v2 globals."
}
+
+ assert {
+ condition = (
+ keys(module.runners) == ["stable"]
+ && length(module.runner_configs) == 0
+ && keys(output.runners_map) == ["stable"]
+ && length(output.runners_map_v2) == 0
+ )
+ error_message = "Stable v1 configurations must route through module.runners and not the experimental runner-config module."
+ }
}
run "v2_inputs_resolve_lane_over_global" {
command = plan
variables {
- experimental_features = ["multi-runner-v2"]
-
tags = {
source = "v1-must-not-leak"
}
@@ -372,4 +383,63 @@ run "v2_inputs_resolve_lane_over_global" {
)
error_message = "v2 inputs must resolve lane overrides before v2 global defaults."
}
+
+ assert {
+ condition = (
+ length(module.runners) == 0
+ && keys(module.runner_configs) == ["lane"]
+ && length(output.runners_map) == 0
+ && keys(output.runners_map_v2) == ["lane"]
+ )
+ error_message = "Experimental v2 configurations must route through module.runner_configs and skip the legacy runners module."
+ }
+}
+
+run "v2_inputs_do_not_require_legacy_arguments" {
+ command = plan
+
+ variables {
+ global_config_compute_provider = {
+ aws = {
+ ec2 = {
+ vpc_id = "vpc-v2"
+ subnet_ids = ["subnet-v2"]
+ runner_binaries = {
+ enabled = false
+ }
+ }
+ }
+ }
+ multi_runner_config = {
+ lane = {
+ orchestration_provider = {
+ webhook = {
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "linux", "x64"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ local.use_v2_config
+ && keys(module.runner_configs) == ["lane"]
+ && length(module.runners) == 0
+ && local.resolved_config.multi_runner_config["lane"].compute_provider.aws.ec2.vpc_id == "vpc-v2"
+ )
+ error_message = "The v2 interface must work without the stable v1 GitHub App, VPC, subnet, or runner configuration inputs."
+ }
}
diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf
new file mode 100644
index 0000000000..f55e20395d
--- /dev/null
+++ b/modules/multi-runner/validations.tf
@@ -0,0 +1,81 @@
+locals {
+ common_validation_errors = concat(
+ alltrue([
+ for app in var.additional_github_apps :
+ (app.key_base64 != null || app.key_base64_ssm != null) &&
+ (app.id != null || app.id_ssm != null)
+ ]) ? [] : ["Each additional GitHub app must provide either key_base64 or key_base64_ssm, and either id or id_ssm."],
+ contains(["STANDARD", "INFREQUENT_ACCESS"], var.log_class) ? [] : ["`log_class` must be either `STANDARD` or `INFREQUENT_ACCESS`."],
+ contains(["first", "random", "all"], var.queue_selection_strategy) ? [] : ["`queue_selection_strategy` value not valid. Valid values are 'first', 'random', 'all'."],
+ contains(["silly", "trace", "debug", "info", "warn", "error", "fatal"], var.log_level) ? [] : ["`log_level` value not valid. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'."],
+ contains(["arm64", "x86_64"], var.lambda_architecture) ? [] : ["`lambda_architecture` value is not valid, valid values are: `arm64` and `x86_64`."],
+ contains(["ENABLED", "DISABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], var.state_event_rule_binaries_syncer) ? [] : ["`state_event_rule_binaries_syncer` value is not valid, valid values are: `ENABLED`, `DISABLED`, `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`."],
+ var.queue_encryption == null || var.queue_encryption.sqs_managed_sse_enabled != null && var.queue_encryption.kms_master_key_id == null && var.queue_encryption.kms_data_key_reuse_period_seconds == null || var.queue_encryption.sqs_managed_sse_enabled == null && var.queue_encryption.kms_master_key_id != null ? [] : ["Invalid configuration for `queue_encryption`. Valid configurations are encryption disabled, enabled via SSE. Or encryption via KMS."],
+ contains(["Standard", "Advanced"], var.matcher_config_parameter_store_tier) ? [] : ["`matcher_config_parameter_store_tier` value is not valid, valid values are: `Standard`, and `Advanced`."],
+ !var.iam_overrides.override_instance_profile || var.iam_overrides.instance_profile_name != null ? [] : ["instance_profile_name must be provided when override_instance_profile is true."],
+ !var.iam_overrides.override_runner_role || var.iam_overrides.runner_role_arn != null ? [] : ["runner_role_arn must be provided when override_runner_role is true."]
+ )
+}
+
+resource "terraform_data" "validate_v1" {
+ count = local.use_v2_config ? 0 : 1
+
+ lifecycle {
+ precondition {
+ condition = length(local.common_validation_errors) == 0
+ error_message = join("\n", local.common_validation_errors)
+ }
+
+ precondition {
+ condition = (
+ (var.github_app.key_base64 != null || var.github_app.key_base64_ssm != null) &&
+ (var.github_app.id != null || var.github_app.id_ssm != null) &&
+ (var.github_app.webhook_secret != null || var.github_app.webhook_secret_ssm != null) &&
+ var.vpc_id != null &&
+ var.subnet_ids != null &&
+ length(var.multi_runner_config) > 0
+ )
+ error_message = "Stable v1 configuration requires github_app, vpc_id, subnet_ids, and multi_runner_config."
+ }
+ }
+}
+
+resource "terraform_data" "validate_v2" {
+ count = local.use_v2_config ? 1 : 0
+
+ lifecycle {
+ precondition {
+ condition = length(local.common_validation_errors) == 0
+ error_message = join("\n", local.common_validation_errors)
+ }
+
+ precondition {
+ condition = (
+ (
+ try(var.global_config_github.app.key_base64, null) != null ||
+ try(var.global_config_github.app.key_base64_ssm, null) != null
+ ) && (
+ try(var.global_config_github.app.id, null) != null ||
+ try(var.global_config_github.app.id_ssm, null) != null
+ ) && (
+ try(var.global_config_github.app.webhook_secret, null) != null ||
+ try(var.global_config_github.app.webhook_secret_ssm, null) != null
+ )
+ )
+ error_message = "Experimental v2 configuration requires a complete GitHub App under global_config_github.app."
+ }
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : (
+ try(config.orchestration_provider.webhook != null, false) &&
+ try(config.compute_provider.aws.ec2 != null, false) &&
+ try(length(config.compute_provider.aws.ec2.instance_types) > 0, false) &&
+ try(config.compute_provider.aws.ec2.vpc_id != null, false) &&
+ try(length(config.compute_provider.aws.ec2.subnet_ids) > 0, false)
+ )
+ ])
+ error_message = "Each experimental v2 runner lane requires a webhook provider, EC2 instance_types, vpc_id, and at least one subnet."
+ }
+ }
+}
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index bddc0873b4..2f41497644 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -1,6 +1,8 @@
variable "github_app" {
description = <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 =