diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index b4edf6f35e..a165fad69a 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -53,6 +53,8 @@ jobs: - default - ephemeral - multi-runner + - multi-runner-v2 + - multi-runner-scale-set - termination-watcher terraform: - "1.4.0" diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 6d91e10c66..c69c2f79fb 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -155,7 +155,9 @@ jobs: "ephemeral", "termination-watcher", "multi-runner", - "external-managed-ssm-secrets" + "multi-runner-v2", + "external-managed-ssm-secrets", + "multi-runner-scale-set" ] defaults: run: 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-scale-set/.terraform.lock.hcl b/examples/multi-runner-scale-set/.terraform.lock.hcl new file mode 100644 index 0000000000..c96d2b19bf --- /dev/null +++ b/examples/multi-runner-scale-set/.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-scale-set/README.md b/examples/multi-runner-scale-set/README.md new file mode 100644 index 0000000000..29c9a21b36 --- /dev/null +++ b/examples/multi-runner-scale-set/README.md @@ -0,0 +1,85 @@ +# Multi-runner scale-set 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 four lanes from one deployment: + +- Linux ARM64 Amazon Linux runners. +- Ephemeral Linux x64 Amazon Linux runners with job retry enabled. +- Linux x64 runners managed by a GitHub Actions scale set. +- 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. + +The scale-set lane uses `orchestration_provider.scale_set`. Its controller +network is configured under the global scale-set block and its GitHub +installation ID is read from the SSM parameter described by `var.scale_set`. + +Configure the GitHub App variables before applying: + +```bash +terraform init +terraform apply \ + -var='github_app={id="123456",key_base64="..."}' \ + -var='scale_set={config_url="https://github.com/example" installation_id_ssm={name="/github/scale-set/installation-id",arn="arn:aws:ssm:eu-west-1:123456789012:parameter/github/scale-set/installation-id"} name="linux-scale-set" id=123}' +``` + +The `github_app` value is sensitive and should be supplied through a secure +variable source in real deployments rather than committed to configuration. +The scale-set installation ID must already exist in the referenced SSM +parameter and the GitHub App must be installed for the configured URL. + + +## 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 | +|------|-------------|------|---------|:--------:| +| [ami](#input\_ami) | Optional AMI configuration keyed by runner lane. |
map(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)
})) | `{}` | no |
+| [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 |
+| [runner\_binaries\_enabled](#input\_runner\_binaries\_enabled) | Whether runner binary synchronization is enabled. | `bool` | `true` | no |
+| [scale\_set](#input\_scale\_set) | GitHub Actions scale-set configuration. | object({
config_url = string
installation_id_ssm = object({
arn = string
name = string
})
name = string
id = number
runner_group_id = optional(number)
}) | 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-scale-set/main.tf b/examples/multi-runner-scale-set/main.tf
new file mode 100644
index 0000000000..816f70e7c3
--- /dev/null
+++ b/examples/multi-runner-scale-set/main.tf
@@ -0,0 +1,210 @@
+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
+
+ experimental_features = ["multi-runner-v2"]
+
+ 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"]
+ }
+ }
+ scale_set = {
+ grouping = {
+ strategy = "runner_config"
+ }
+ network = {
+ vpc_id = module.base.vpc.vpc_id
+ subnet_ids = module.base.vpc.private_subnets
+ }
+ }
+ }
+
+ 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 = var.runner_binaries_enabled
+ }
+ }
+ }
+ }
+
+ 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"]
+ ami = lookup(var.ami, "linux-arm64", null)
+ }
+ }
+ }
+ }
+
+ 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"]
+ ami = lookup(var.ami, "linux-x64", null)
+ }
+ }
+ }
+ }
+
+ linux-scale-set = {
+ runner = {
+ name_prefix = "scale-set-"
+ extra_labels = ["scale-set"]
+ }
+ orchestration_provider = {
+ scale_set = {
+ github = {
+ config_url = var.scale_set.config_url
+ installation_id_ssm = var.scale_set.installation_id_ssm
+ }
+ name = var.scale_set.name
+ id = var.scale_set.id
+ runner_group_id = var.scale_set.runner_group_id
+ min_runners = 0
+ max_runners = 10
+ work_folder = "_work/scale-set"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ ami = lookup(var.ami, "linux-scale-set", null)
+ }
+ }
+ }
+ }
+
+ 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 = lookup(var.ami, "windows-x64", null)
+ }
+ }
+ }
+ }
+ }
+}
+
+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-scale-set/outputs.tf b/examples/multi-runner-scale-set/outputs.tf
new file mode 100644
index 0000000000..1feaf2e671
--- /dev/null
+++ b/examples/multi-runner-scale-set/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-scale-set/providers.tf b/examples/multi-runner-scale-set/providers.tf
new file mode 100644
index 0000000000..eca2fe96a7
--- /dev/null
+++ b/examples/multi-runner-scale-set/providers.tf
@@ -0,0 +1,9 @@
+provider "aws" {
+ region = local.aws_region
+
+ default_tags {
+ tags = {
+ Example = local.environment
+ }
+ }
+}
diff --git a/examples/multi-runner-scale-set/variables.tf b/examples/multi-runner-scale-set/variables.tf
new file mode 100644
index 0000000000..1c4fcc4e4d
--- /dev/null
+++ b/examples/multi-runner-scale-set/variables.tf
@@ -0,0 +1,61 @@
+variable "github_app" {
+ description = "GitHub App ID and base64-encoded private key."
+
+ type = object({
+ id = string
+ key_base64 = string
+ })
+ sensitive = true
+}
+
+variable "scale_set" {
+ description = "GitHub Actions scale-set configuration."
+
+ type = object({
+ config_url = string
+ installation_id_ssm = object({
+ arn = string
+ name = string
+ })
+ name = string
+ id = number
+ runner_group_id = optional(number)
+ })
+}
+
+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"
+}
+
+variable "runner_binaries_enabled" {
+ description = "Whether runner binary synchronization is enabled."
+
+ type = bool
+ default = true
+}
+
+variable "ami" {
+ description = "Optional AMI configuration keyed by runner lane."
+
+ type = map(object({
+ filter = optional(map(list(string)), { state = ["available"] })
+ owners = optional(list(string), ["amazon"])
+ id_ssm_parameter = optional(object({
+ arn = string
+ }), null)
+ kms_key = optional(object({
+ arn = string
+ }), null)
+ }))
+ default = {}
+}
diff --git a/examples/multi-runner-scale-set/versions.tf b/examples/multi-runner-scale-set/versions.tf
new file mode 100644
index 0000000000..1dfb3e5774
--- /dev/null
+++ b/examples/multi-runner-scale-set/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-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..f18735e35d
--- /dev/null
+++ b/examples/multi-runner-v2/README.md
@@ -0,0 +1,78 @@
+# 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 configuration are under each lane's compute provider
+block. The optional `ami` variable can provide per-lane AMI filters and owners,
+which is useful for test environments with locally registered images.
+
+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 |
+|------|-------------|------|---------|:--------:|
+| [ami](#input\_ami) | Optional AMI configuration keyed by runner lane. | map(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)
})) | `{}` | no |
+| [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 |
+| [runner\_binaries\_enabled](#input\_runner\_binaries\_enabled) | Whether runner binary synchronization is enabled. | `bool` | `true` | no |
+
+## 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..2076c2a4a2
--- /dev/null
+++ b/examples/multi-runner-v2/main.tf
@@ -0,0 +1,178 @@
+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 = var.runner_binaries_enabled
+ }
+ }
+ }
+ }
+
+ 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"]
+ ami = lookup(var.ami, "linux-arm64", null)
+ }
+ }
+ }
+ }
+
+ 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"]
+ ami = lookup(var.ami, "linux-x64", null)
+ }
+ }
+ }
+ }
+
+ 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 = lookup(var.ami, "windows-x64", {
+ filter = {
+ name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"]
+ state = ["available"]
+ }
+ owners = ["amazon"]
+ id_ssm_parameter = null
+ kms_key = null
+ })
+ }
+ }
+ }
+ }
+ }
+}
+
+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..b6f4d7588b
--- /dev/null
+++ b/examples/multi-runner-v2/variables.tf
@@ -0,0 +1,46 @@
+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"
+}
+
+variable "runner_binaries_enabled" {
+ description = "Whether runner binary synchronization is enabled."
+
+ type = bool
+ default = true
+}
+
+variable "ami" {
+ description = "Optional AMI configuration keyed by runner lane."
+
+ type = map(object({
+ filter = optional(map(list(string)), { state = ["available"] })
+ owners = optional(list(string), ["amazon"])
+ id_ssm_parameter = optional(object({
+ arn = string
+ }), null)
+ kms_key = optional(object({
+ arn = string
+ }), null)
+ }))
+ default = {}
+}
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/ec2/outputs.tf b/modules/compute-providers/aws/ec2/outputs.tf
index 422383df0f..83b2647fca 100644
--- a/modules/compute-providers/aws/ec2/outputs.tf
+++ b/modules/compute-providers/aws/ec2/outputs.tf
@@ -16,6 +16,8 @@ output "resources" {
output "provider" {
description = "Nested EC2 compute-provider contract consumed by runner-config."
value = {
+ type = "ec2"
+ capabilities = { scale_set = local.scale_set_capability }
environment_variables = local.provider_environment_variables
policies = local.provider_policies
resources = local.provider_resources
diff --git a/modules/compute-providers/aws/ec2/scale-set.tf b/modules/compute-providers/aws/ec2/scale-set.tf
new file mode 100644
index 0000000000..6d9018a36e
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/scale-set.tf
@@ -0,0 +1,255 @@
+# Provider-owned runtime and IAM fragments for the additive scale-set
+# orchestration capability. GitHub credentials, GitHub scope, desired capacity,
+# and boot timeout remain orchestration-owned and are not serialized here.
+locals {
+ scale_set_ec2_instance_criteria = merge(
+ {
+ instanceTypes = var.config.instance_types
+ targetCapacityType = var.config.instance_target_capacity_type
+ instanceAllocationStrategy = var.config.instance_allocation_strategy
+ },
+ var.config.instance_type_priorities == null ? {} : {
+ instanceTypePriorities = var.config.instance_type_priorities
+ },
+ var.config.instance_max_spot_price == null ? {} : {
+ maxSpotPrice = var.config.instance_max_spot_price
+ },
+ )
+
+ scale_set_runtime_configuration = merge(
+ {
+ region = var.aws_region
+ environment = var.prefix
+ runnerNamePrefix = var.runner.name_prefix
+ jitConfigParameterPath = "${var.ssm.paths.root}/${var.ssm.paths.tokens}"
+ subnets = var.config.subnet_ids
+ launchTemplateName = aws_launch_template.runner.name
+ ec2instanceCriteria = local.scale_set_ec2_instance_criteria
+ onDemandFailoverOnError = var.config.on_demand_failover_for_errors
+ scaleErrors = var.config.scale_errors
+ useDedicatedHost = var.config.use_dedicated_host
+ ssmParameterTags = [
+ for key in sort(keys(local.ssm_parameter_tags)) : {
+ Key = key
+ Value = local.ssm_parameter_tags[key]
+ }
+ ]
+ },
+ local.ami_id_ssm_external ? {
+ amiIdSsmParameterName = local.ami_id_ssm_parameter_name
+ } : {},
+ )
+
+ scale_set_owned_instance_conditions = [
+ {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:Application"
+ values = toset(["github-action-runner"])
+ },
+ {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:created_by"
+ values = toset(["scale-set-service"])
+ },
+ {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:environment"
+ values = toset([var.prefix])
+ },
+ ]
+
+ scale_set_owned_request_conditions = [
+ {
+ test = "StringEquals"
+ variable = "aws:RequestTag/ghr:Application"
+ values = toset(["github-action-runner"])
+ },
+ {
+ test = "StringEquals"
+ variable = "aws:RequestTag/ghr:created_by"
+ values = toset(["scale-set-service"])
+ },
+ {
+ test = "StringEquals"
+ variable = "aws:RequestTag/ghr:environment"
+ values = toset([var.prefix])
+ },
+ ]
+
+ scale_set_launch_dependency_resources = toset(concat(
+ [
+ "arn:${var.aws_partition}:ec2:${var.aws_region}::image/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:*:snapshot/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:dedicated-host/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:network-interface/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:placement-group/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:security-group/*",
+ aws_launch_template.runner.arn,
+ ],
+ [
+ for subnet_id in var.config.subnet_ids :
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subnet/${subnet_id}"
+ ],
+ var.config.key_name == null ? [] : [
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:key-pair/${var.config.key_name}",
+ ],
+ ))
+
+ scale_set_create_fleet_dependency_resources = toset(concat(
+ [
+ "arn:${var.aws_partition}:ec2:${var.aws_region}::image/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:placement-group/*",
+ aws_launch_template.runner.arn,
+ ],
+ [
+ for subnet_id in var.config.subnet_ids :
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subnet/${subnet_id}"
+ ],
+ ))
+
+ scale_set_iam_statements = merge(
+ {
+ describe_ec2 = {
+ actions = toset([
+ "ec2:DescribeInstances",
+ "ec2:DescribeLaunchTemplateVersions",
+ "ec2:DescribeTags",
+ ])
+ # These EC2 Describe APIs do not support resource-level permissions.
+ resources = toset(["*"])
+ conditions = []
+ }
+ create_fleet_dependencies = {
+ actions = toset(["ec2:CreateFleet"])
+ resources = local.scale_set_create_fleet_dependency_resources
+ conditions = []
+ }
+ create_owned_fleet_capacity = {
+ actions = toset(["ec2:CreateFleet"])
+ resources = toset([
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:fleet/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:volume/*",
+ ])
+ conditions = local.scale_set_owned_request_conditions
+ }
+ run_instances_dependencies = {
+ actions = toset(["ec2:RunInstances"])
+ resources = local.scale_set_launch_dependency_resources
+ conditions = []
+ }
+ run_owned_instances = {
+ actions = toset(["ec2:RunInstances"])
+ resources = toset([
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:volume/*",
+ ])
+ conditions = local.scale_set_owned_request_conditions
+ }
+ tag_runners_on_create = {
+ actions = toset(["ec2:CreateTags"])
+ resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*/*"])
+ conditions = [
+ {
+ test = "StringEquals"
+ variable = "ec2:CreateAction"
+ values = toset(["CreateFleet", "RunInstances"])
+ },
+ ]
+ }
+ update_owned_runner_tags = {
+ actions = toset(["ec2:CreateTags"])
+ resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"])
+ conditions = concat(local.scale_set_owned_instance_conditions, [
+ {
+ test = "ForAllValues:StringEquals"
+ variable = "aws:TagKeys"
+ values = toset([
+ "ghr:github_runner_id",
+ "ghr:runner_name",
+ "ghr:scale_set_state",
+ ])
+ },
+ ])
+ }
+ terminate_owned_runners = {
+ actions = toset(["ec2:TerminateInstances"])
+ resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"])
+ conditions = local.scale_set_owned_instance_conditions
+ }
+ pass_runner_role = {
+ actions = toset(["iam:PassRole"])
+ resources = toset([var.runner.iam.role.arn])
+ conditions = [
+ {
+ test = "StringEquals"
+ variable = "iam:PassedToService"
+ values = toset(["ec2.amazonaws.com"])
+ },
+ ]
+ }
+ publish_runner_jit_configuration = {
+ actions = toset([
+ "ssm:AddTagsToResource",
+ "ssm:DeleteParameter",
+ "ssm:PutParameter",
+ ])
+ resources = toset([
+ "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.tokens}/*",
+ ])
+ conditions = []
+ }
+ },
+ local.ami_id_ssm_external ? {
+ read_external_ami_parameter = {
+ actions = toset(["ssm:GetParameter"])
+ resources = toset([local.ami_id_ssm_parameter_arn])
+ conditions = []
+ }
+ } : {},
+ local.ami_kms_key_enabled ? {
+ use_ami_kms_key = {
+ actions = toset([
+ "kms:Decrypt",
+ "kms:DescribeKey",
+ "kms:ReEncryptFrom",
+ "kms:ReEncryptTo",
+ ])
+ resources = toset([local.ami_kms_key_arn])
+ conditions = []
+ }
+ create_ami_kms_grant = {
+ actions = toset(["kms:CreateGrant"])
+ resources = toset([local.ami_kms_key_arn])
+ conditions = [
+ {
+ test = "Bool"
+ variable = "kms:GrantIsForAWSResource"
+ values = toset(["true"])
+ },
+ ]
+ }
+ } : {},
+ var.config.create_service_linked_role_spot ? {
+ create_spot_service_linked_role = {
+ actions = toset(["iam:CreateServiceLinkedRole"])
+ resources = toset([
+ "arn:${var.aws_partition}:iam::${data.aws_caller_identity.current.account_id}:role/aws-service-role/spot.amazonaws.com/AWSServiceRoleForEC2Spot",
+ ])
+ conditions = [
+ {
+ test = "StringEquals"
+ variable = "iam:AWSServiceName"
+ values = toset(["spot.amazonaws.com"])
+ },
+ ]
+ }
+ } : {},
+ )
+
+ scale_set_capability = {
+ configuration_json = jsonencode(local.scale_set_runtime_configuration)
+ environment_variables = {}
+ iam_statements = local.scale_set_iam_statements
+ }
+}
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..ca124d20b2 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
@@ -154,7 +119,9 @@ multi_runner_config = {
|------|--------|---------|
| [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a |
| [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a |
+| [orchestration\_scale\_set](#module\_orchestration\_scale\_set) | ../orchestration-providers/scale-set | 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 +135,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,13 +161,13 @@ 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 |
| [global\_config\_lambda](#input\_global\_config\_lambda) | Global Lambda configuration shared by all runner lanes.object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | `{}` | no |
| [global\_config\_observability](#input\_global\_config\_observability) | Global observability configuration shared by all runner lanes.object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
-| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
repository_white_list = optional(list(string), [])
}), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}) | `{}` | no |
+| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
repository_white_list = optional(list(string), [])
}), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
scale_set = optional(object({
grouping = optional(object({
strategy = optional(string, "compute_provider")
custom = optional(object({
groups = map(object({
runner_configs = set(string)
}))
}), null)
}), {})
container = optional(object({
image = optional(string, null)
user = optional(string, "10001:10001")
health_port = optional(number, 8080)
health_path = optional(string, "/healthz")
health_check_command = optional(list(string), null)
health_check_interval = optional(number, 30)
health_check_timeout = optional(number, 5)
health_check_retries = optional(number, 3)
health_check_start_period = optional(number, 30)
health_stale_after_seconds = optional(number, 180)
shutdown_timeout_seconds = optional(number, 110)
session_close_timeout_seconds = optional(number, 10)
reconnect_initial_backoff_seconds = optional(number, 1)
reconnect_max_backoff_seconds = optional(number, 30)
stop_timeout_seconds = optional(number, 120)
ecr_repository = optional(object({
arn = string
}), null)
}), {})
config_store = optional(object({
path_prefix = optional(string, null)
tier = optional(string, "Standard")
tags = optional(map(string), {})
}), {})
ecs = optional(object({
cluster = optional(object({
mode = optional(string, "managed")
arn = optional(string, null)
name = optional(string, null)
container_insights = optional(bool, true)
}), {})
task = optional(object({
cpu = optional(number, 512)
memory = optional(number, 1024)
cpu_architecture = optional(string, "X86_64")
ephemeral_storage = optional(object({
size_in_gib = number
}), null)
}), {})
service = optional(object({
platform_version = optional(string, "LATEST")
}), {})
iam = optional(object({
path = optional(string, "/")
permissions_boundary = optional(string, null)
}), {})
}), {})
network = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(set(string), null)
https_egress = optional(object({
ipv4_cidrs = optional(set(string), ["0.0.0.0/0"])
ipv6_cidrs = optional(set(string), [])
}), {})
}), {})
logging = optional(object({
retention_in_days = optional(number, 30)
kms_key_arn = optional(string, null)
log_group_class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tags = optional(map(string), {})
}), {})
}) | `{}` | no |
| [global\_config\_ssm](#input\_global\_config\_ssm) | Global SSM configuration shared by all runner lanes.object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}) | `{}` | no |
| [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. | object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}) | {
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
} | no |
| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
@@ -220,7 +189,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)
scale_set = optional(object({
github = object({
config_url = string
installation_id_ssm = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
force_ghes = optional(bool, null)
})
name = string
id = number
runner_group_id = optional(number, null)
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
session_owner = optional(string, null)
work_folder = optional(string, null)
}), 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 +217,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 +239,8 @@ 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 |
+| [scale\_set](#output\_scale\_set) | Shared scale-set orchestration resources, or null when no runner configuration selects scale\_set. |
| [ssm\_parameters](#output\_ssm\_parameters) | n/a |
| [webhook](#output\_webhook) | n/a |
diff --git a/modules/multi-runner/config.experimental.effective.tf b/modules/multi-runner/config.experimental.effective.tf
index 1a53a16520..0d6acb0112 100644
--- a/modules/multi-runner/config.experimental.effective.tf
+++ b/modules/multi-runner/config.experimental.effective.tf
@@ -35,6 +35,7 @@ locals {
artifact = local.normalized_config.orchestration_provider.webhook.lambda.artifact
})
})
+ scale_set = v.orchestration_provider.scale_set
}
ssm = merge(v.ssm, {
diff --git a/modules/multi-runner/config.experimental.resolved.tf b/modules/multi-runner/config.experimental.resolved.tf
index b7e2e26ab0..5b12dea026 100644
--- a/modules/multi-runner/config.experimental.resolved.tf
+++ b/modules/multi-runner/config.experimental.resolved.tf
@@ -291,6 +291,7 @@ locals {
tags = merge(local.normalized_config.orchestration_provider.webhook.queue.tags, v.orchestration_provider.webhook.queue.tags)
})
})
+ scale_set = try(v.orchestration_provider.scale_set, null)
}
ssm = merge(v.ssm, {
diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf
index 07df28e2e7..d0cf4eceb8 100644
--- a/modules/multi-runner/config.experimental.translation.tf
+++ b/modules/multi-runner/config.experimental.translation.tf
@@ -140,6 +140,7 @@ locals {
encryption = var.queue_encryption
}
}
+ scale_set = null
}
stable_to_v2_ssm = {
@@ -425,6 +426,7 @@ locals {
}
}
}
+ scale_set = null
}
ssm = {
diff --git a/modules/multi-runner/orchestration-provider.scale-set.tf b/modules/multi-runner/orchestration-provider.scale-set.tf
new file mode 100644
index 0000000000..8a38f12fa3
--- /dev/null
+++ b/modules/multi-runner/orchestration-provider.scale-set.tf
@@ -0,0 +1,74 @@
+locals {
+ scale_set_runner_config = {
+ for runner_name, runner_config in local.effective_config.multi_runner_config :
+ runner_name => runner_config
+ if runner_config.orchestration_provider.scale_set != null
+ }
+
+ scale_set_runner_configs = {
+ for runner_name, runner_config in local.scale_set_runner_config : runner_name => {
+ github = {
+ config_url = runner_config.orchestration_provider.scale_set.github.config_url
+ app = {
+ app_id = {
+ name = local.primary_app_id.name
+ arn = local.primary_app_id.arn
+ kms_key_arn = local.effective_config.ssm.kms_key_id
+ }
+ private_key = {
+ name = local.primary_app_key_base64.name
+ arn = local.primary_app_key_base64.arn
+ kms_key_arn = local.effective_config.ssm.kms_key_id
+ }
+ installation_id = {
+ name = runner_config.orchestration_provider.scale_set.github.installation_id_ssm.name
+ arn = runner_config.orchestration_provider.scale_set.github.installation_id_ssm.arn
+ kms_key_arn = runner_config.orchestration_provider.scale_set.github.installation_id_ssm.kms_key_arn
+ }
+ }
+ force_ghes = try(coalesce(
+ runner_config.orchestration_provider.scale_set.github.force_ghes,
+ local.effective_config.github.enterprise_server.url != null,
+ ), false)
+ ssl_verify = local.effective_config.github.enterprise_server.ssl_verify
+ user_agent = local.effective_config.github.user_agent
+ }
+ scale_set = {
+ name = runner_config.orchestration_provider.scale_set.name
+ id = runner_config.orchestration_provider.scale_set.id
+ runner_group_id = runner_config.orchestration_provider.scale_set.runner_group_id
+ min_runners = runner_config.orchestration_provider.scale_set.min_runners
+ max_runners = runner_config.orchestration_provider.scale_set.max_runners
+ boot_time_in_minutes = runner_config.orchestration_provider.scale_set.boot_time_in_minutes
+ session_owner = runner_config.orchestration_provider.scale_set.session_owner
+ }
+ work_folder = runner_config.orchestration_provider.scale_set.work_folder
+ }
+ }
+
+ scale_set_compute_provider_contracts = {
+ for runner_name in keys(local.scale_set_runner_configs) :
+ runner_name => module.runner_configs[runner_name].compute_provider_contract
+ }
+}
+
+module "orchestration_scale_set" {
+ source = "../orchestration-providers/scale-set"
+ count = length(local.scale_set_runner_configs) > 0 ? 1 : 0
+
+ prefix = var.prefix
+ runner_configs = local.scale_set_runner_configs
+ compute_provider_contracts = local.scale_set_compute_provider_contracts
+
+ grouping = try(local.effective_config.orchestration_provider.scale_set.grouping, {})
+ container = try(local.effective_config.orchestration_provider.scale_set.container, {})
+ config_store = try(local.effective_config.orchestration_provider.scale_set.config_store, {})
+ ecs = try(local.effective_config.orchestration_provider.scale_set.ecs, {})
+ network = try(local.effective_config.orchestration_provider.scale_set.network, {})
+ logging = try(local.effective_config.orchestration_provider.scale_set.logging, {})
+ tags = merge(
+ local.effective_config.tags,
+ try(local.effective_config.orchestration_provider.scale_set.tags, {}),
+ { "ghr:environment" = var.prefix },
+ )
+}
diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf
index 7c4a9807d1..0a9a1d5ac9 100644
--- a/modules/multi-runner/outputs.tf
+++ b/modules/multi-runner/outputs.tf
@@ -21,6 +21,28 @@ 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 "scale_set" {
+ description = "Shared scale-set orchestration resources, or null when no runner configuration selects scale_set."
+ value = length(module.orchestration_scale_set) == 0 ? null : {
+ cluster = one(module.orchestration_scale_set[*].cluster)
+ controller_groups = one(module.orchestration_scale_set[*].controller_groups)
+ reconciler_config_parameters = one(module.orchestration_scale_set[*].reconciler_config_parameters)
+ resolved_container_image = one(module.orchestration_scale_set[*].resolved_container_image)
+ }
+}
+
output "binaries_syncer_map" {
value = { for runner_binary_key, runner_binary in module.runner_binaries : runner_binary_key => {
lambda = runner_binary.lambda
@@ -32,15 +54,15 @@ output "binaries_syncer_map" {
}
output "webhook" {
- value = {
- gateway = module.webhook.gateway
- lambda = module.webhook.lambda
- lambda_log_group = module.webhook.lambda_log_group
- lambda_role = module.webhook.role
- endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}"
- webhook = module.webhook.webhook
- dispatcher = local.effective_config.orchestration_provider.webhook.eventbridge.enabled ? module.webhook.dispatcher : null
- eventbridge = local.effective_config.orchestration_provider.webhook.eventbridge.enabled ? module.webhook.eventbridge : null
+ value = length(module.webhook) == 0 ? null : {
+ gateway = module.webhook[0].gateway
+ lambda = module.webhook[0].lambda
+ lambda_log_group = module.webhook[0].lambda_log_group
+ lambda_role = module.webhook[0].role
+ endpoint = "${module.webhook[0].gateway.api_endpoint}/${module.webhook[0].endpoint_relative_path}"
+ webhook = module.webhook[0].webhook
+ dispatcher = length(module.webhook) > 0 && try(local.effective_config.orchestration_provider.webhook.eventbridge.enabled, false) ? module.webhook[0].dispatcher : null
+ eventbridge = length(module.webhook) > 0 && try(local.effective_config.orchestration_provider.webhook.eventbridge.enabled, false) ? module.webhook[0].eventbridge : null
}
}
diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf
index 0f57020571..b8794de893 100644
--- a/modules/multi-runner/queues.tf
+++ b/modules/multi-runner/queues.tf
@@ -27,7 +27,7 @@ data "aws_iam_policy_document" "deny_insecure_transport" {
}
resource "aws_sqs_queue" "queued_builds" {
- for_each = local.effective_config.multi_runner_config
+ for_each = local.webhook_runner_config
name = "${var.prefix}-${each.key}-queued-builds"
delay_seconds = each.value.orchestration_provider.webhook.queue.delay_webhook_event
visibility_timeout_seconds = each.value.orchestration_provider.webhook.queue.visibility_timeout_seconds
@@ -50,14 +50,14 @@ resource "aws_sqs_queue" "queued_builds" {
}
resource "aws_sqs_queue_policy" "build_queue_policy" {
- for_each = local.effective_config.multi_runner_config
+ for_each = local.webhook_runner_config
queue_url = aws_sqs_queue.queued_builds[each.key].id
policy = data.aws_iam_policy_document.deny_insecure_transport.json
}
resource "aws_sqs_queue" "queued_builds_dlq" {
for_each = {
- for config, values in local.effective_config.multi_runner_config : config => values
+ for config, values in local.webhook_runner_config : config => values
if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled
}
name = "${var.prefix}-${each.key}-queued-builds_dead_letter"
@@ -74,7 +74,7 @@ resource "aws_sqs_queue" "queued_builds_dlq" {
resource "aws_sqs_queue_policy" "build_queue_dlq_policy" {
for_each = {
- for config, values in local.effective_config.multi_runner_config : config => values
+ for config, values in local.webhook_runner_config : config => values
if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled
}
queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id
diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf
new file mode 100644
index 0000000000..9c081d4273
--- /dev/null
+++ b/modules/multi-runner/runners.experimental.tf
@@ -0,0 +1,39 @@
+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
+ }
+ scale_set = each.value.orchestration_provider.scale_set
+ }
+ 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..22664c59b6 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"
}
@@ -221,6 +232,9 @@ run "v2_inputs_resolve_lane_over_global" {
ec2 = {
vpc_id = "vpc-global"
subnet_ids = ["subnet-global"]
+ runner_binaries = {
+ enabled = false
+ }
instance_termination_watcher = {
features = {
runner_deregistration = {
@@ -372,4 +386,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/tests/config-translation.tftest.hcl b/modules/multi-runner/tests/config-translation.tftest.hcl
index 132814b156..64f0e3f764 100644
--- a/modules/multi-runner/tests/config-translation.tftest.hcl
+++ b/modules/multi-runner/tests/config-translation.tftest.hcl
@@ -454,6 +454,7 @@ run "non_empty_v2_map_is_authoritative" {
vpc_id = "vpc-experimental-default"
subnet_ids = ["subnet-experimental-default"]
runner_binaries = {
+ enabled = false
syncer = {
artifact = {
s3 = {
@@ -520,6 +521,15 @@ run "v2_entry_without_matcher_config_is_authoritative" {
}
}
+ global_config_orchestration_provider = {
+ scale_set = {
+ network = {
+ vpc_id = "vpc-no-matcher"
+ subnet_ids = ["subnet-no-matcher"]
+ }
+ }
+ }
+
multi_runner_config = {
no_matcher = {
runner = {
@@ -527,7 +537,17 @@ run "v2_entry_without_matcher_config_is_authoritative" {
architecture = "x64"
}
orchestration_provider = {
- webhook = {}
+ scale_set = {
+ github = {
+ config_url = "https://github.com/example"
+ installation_id_ssm = {
+ name = "/tests/scale-set/installation-id"
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/scale-set/installation-id"
+ }
+ }
+ name = "no-matcher-scale-set"
+ id = 42
+ }
}
compute_provider = {
aws = {
@@ -547,6 +567,7 @@ run "v2_entry_without_matcher_config_is_authoritative" {
local.use_v2_config
&& toset(keys(local.normalized_config.multi_runner_config)) == toset(["no_matcher"])
&& try(local.normalized_config.multi_runner_config["no_matcher"].orchestration_provider.webhook.matcherConfig, null) == null
+ && local.normalized_config.multi_runner_config["no_matcher"].orchestration_provider.scale_set.name == "no-matcher-scale-set"
)
error_message = "A v2 runner entry must be recognized without requiring matcher configuration."
}
@@ -624,6 +645,7 @@ run "lane_values_override_experimental_globals" {
vpc_id = "vpc-experimental"
subnet_ids = ["subnet-global"]
runner_binaries = {
+ enabled = false
syncer = {
artifact = {
s3 = {
@@ -769,6 +791,7 @@ run "global_external_runner_role_suppresses_inherited_iam_overrides" {
vpc_id = "vpc-global"
subnet_ids = ["subnet-global"]
runner_binaries = {
+ enabled = false
syncer = {
artifact = {
s3 = {
diff --git a/modules/multi-runner/tests/scale-set.tftest.hcl b/modules/multi-runner/tests/scale-set.tftest.hcl
new file mode 100644
index 0000000000..7aeebf5c43
--- /dev/null
+++ b/modules/multi-runner/tests/scale-set.tftest.hcl
@@ -0,0 +1,217 @@
+mock_provider "aws" {
+ mock_data "aws_caller_identity" {
+ defaults = {
+ account_id = "123456789012"
+ }
+ }
+
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ }
+
+ mock_resource "aws_iam_role" {
+ defaults = {
+ arn = "arn:aws:iam::123456789012:role/test-role"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_event_bus" {
+ defaults = {
+ arn = "arn:aws:events:eu-west-1:123456789012:event-bus/test"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_event_rule" {
+ defaults = {
+ arn = "arn:aws:events:eu-west-1:123456789012:rule/test"
+ }
+ }
+
+ mock_resource "aws_lambda_function" {
+ defaults = {
+ arn = "arn:aws:lambda:eu-west-1:123456789012:function:test"
+ }
+ }
+
+ mock_resource "aws_sqs_queue" {
+ defaults = {
+ arn = "arn:aws:sqs:eu-west-1:123456789012:test"
+ }
+ }
+
+ mock_resource "aws_s3_bucket" {
+ defaults = {
+ arn = "arn:aws:s3:::test-runner-binaries"
+ id = "test-runner-binaries"
+ }
+ }
+
+ mock_resource "aws_apigatewayv2_api" {
+ defaults = {
+ execution_arn = "arn:aws:execute-api:eu-west-1:123456789012:test"
+ }
+ }
+}
+
+mock_provider "random" {}
+mock_provider "null" {}
+
+variables {
+ aws_region = "eu-west-1"
+ aws_partition = "aws"
+ prefix = "scale-set-test"
+
+ experimental_features = ["multi-runner-v2"]
+
+ global_config = {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ }
+ }
+
+ global_config_github = {
+ app = {
+ key_base64 = "test-app-key"
+ id = "test-app-id"
+ webhook_secret = "test-webhook-secret"
+ }
+ }
+
+ global_config_lambda = {
+ artifact = {
+ s3 = {
+ bucket = "test-lambda-artifacts"
+ }
+ }
+ }
+
+ global_config_orchestration_provider = {
+ scale_set = {
+ grouping = {
+ strategy = "runner_config"
+ }
+ container = {
+ image = "public.ecr.aws/example/scale-set-controller@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ }
+ config_store = {
+ path_prefix = "/test/scale-set"
+ }
+ ecs = {
+ task = {
+ cpu = 512
+ memory = 1024
+ cpu_architecture = "X86_64"
+ }
+ }
+ network = {
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set-a", "subnet-scale-set-b"]
+ }
+ logging = {
+ retention_in_days = 7
+ }
+ }
+ }
+
+ global_config_ssm = {
+ kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/test"
+ housekeeper = {
+ lambda = {
+ artifact = {
+ s3 = {
+ key = "housekeeper.zip"
+ }
+ }
+ }
+ }
+ }
+
+ global_config_compute_provider = {
+ aws = {
+ ec2 = {
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set-a"]
+ runner_binaries = {
+ enabled = false
+ }
+ }
+ }
+ }
+
+ multi_runner_config = {
+ linux = {
+ runner = {
+ name_prefix = "linux-"
+ }
+ orchestration_provider = {
+ scale_set = {
+ github = {
+ config_url = "https://github.com/example"
+ installation_id_ssm = {
+ name = "/github/scale-set/installation-id"
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/scale-set/installation-id"
+ }
+ }
+ name = "linux-scale-set"
+ id = 42
+ runner_group_id = 7
+ min_runners = 1
+ max_runners = 8
+ boot_time_in_minutes = 12
+ session_owner = "test-owner"
+ work_folder = "_work/linux"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m7i.large"]
+ subnet_ids = ["subnet-scale-set-a"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+run "routes_scale_set_through_runner_config_and_shared_controller" {
+ command = plan
+
+ assert {
+ condition = (
+ local.use_v2_config
+ && keys(local.resolved_config.multi_runner_config) == ["linux"]
+ && local.resolved_config.multi_runner_config.linux.orchestration_provider.scale_set.name == "linux-scale-set"
+ && local.resolved_config.multi_runner_config.linux.orchestration_provider.scale_set.id == 42
+ )
+ error_message = "The multi-runner resolver must preserve the lane scale_set contract and its plan-known identity."
+ }
+
+ assert {
+ condition = (
+ length(module.runners) == 0
+ && keys(module.runner_configs) == ["linux"]
+ && output.runners_map_v2.linux.orchestration_provider.scale_set.name == "linux-scale-set"
+ && output.runners_map_v2.linux.orchestration_provider.scale_set.id == 42
+ && output.runners_map_v2.linux.provider.aws.ec2 != null
+ )
+ error_message = "Scale-set lanes must use runner-config and expose the selected compute-provider namespace."
+ }
+
+ assert {
+ condition = (
+ output.scale_set != null
+ && output.scale_set.cluster.managed
+ && keys(output.scale_set.controller_groups) == ["linux"]
+ && output.scale_set.controller_groups.linux.runner_configs == ["linux"]
+ && output.scale_set.reconciler_config_parameters["linux/linux"].tier == "Standard"
+ )
+ error_message = "Multi-runner must create one shared scale-set controller group and reconciler parameter for the selected lane."
+ }
+}
diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf
new file mode 100644
index 0000000000..be0ae4fcca
--- /dev/null
+++ b/modules/multi-runner/validations.tf
@@ -0,0 +1,86 @@
+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(length(config.orchestration_provider.webhook.matcherConfig.labelMatchers) > 0, false)
+ ) || try(config.orchestration_provider.scale_set != 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 either a webhook matcher or scale_set orchestration, plus EC2 instance_types, vpc_id, and at least one subnet."
+ }
+ }
+}
diff --git a/modules/multi-runner/variables.experimental.orchestration-provider.tf b/modules/multi-runner/variables.experimental.orchestration-provider.tf
index fd962f9632..acb1b24a1e 100644
--- a/modules/multi-runner/variables.experimental.orchestration-provider.tf
+++ b/modules/multi-runner/variables.experimental.orchestration-provider.tf
@@ -171,6 +171,81 @@ variable "global_config_orchestration_provider" {
sqs_managed_sse_enabled = true
})
}), {})
+
+ }), {})
+
+ scale_set = optional(object({
+ grouping = optional(object({
+ strategy = optional(string, "compute_provider")
+ custom = optional(object({
+ groups = map(object({
+ runner_configs = set(string)
+ }))
+ }), null)
+ }), {})
+ container = optional(object({
+ image = optional(string, null)
+ user = optional(string, "10001:10001")
+ health_port = optional(number, 8080)
+ health_path = optional(string, "/healthz")
+ health_check_command = optional(list(string), null)
+ health_check_interval = optional(number, 30)
+ health_check_timeout = optional(number, 5)
+ health_check_retries = optional(number, 3)
+ health_check_start_period = optional(number, 30)
+ health_stale_after_seconds = optional(number, 180)
+ shutdown_timeout_seconds = optional(number, 110)
+ session_close_timeout_seconds = optional(number, 10)
+ reconnect_initial_backoff_seconds = optional(number, 1)
+ reconnect_max_backoff_seconds = optional(number, 30)
+ stop_timeout_seconds = optional(number, 120)
+ ecr_repository = optional(object({
+ arn = string
+ }), null)
+ }), {})
+ config_store = optional(object({
+ path_prefix = optional(string, null)
+ tier = optional(string, "Standard")
+ tags = optional(map(string), {})
+ }), {})
+ ecs = optional(object({
+ cluster = optional(object({
+ mode = optional(string, "managed")
+ arn = optional(string, null)
+ name = optional(string, null)
+ container_insights = optional(bool, true)
+ }), {})
+ task = optional(object({
+ cpu = optional(number, 512)
+ memory = optional(number, 1024)
+ cpu_architecture = optional(string, "X86_64")
+ ephemeral_storage = optional(object({
+ size_in_gib = number
+ }), null)
+ }), {})
+ service = optional(object({
+ platform_version = optional(string, "LATEST")
+ }), {})
+ iam = optional(object({
+ path = optional(string, "/")
+ permissions_boundary = optional(string, null)
+ }), {})
+ }), {})
+ network = optional(object({
+ vpc_id = optional(string, null)
+ subnet_ids = optional(set(string), null)
+ https_egress = optional(object({
+ ipv4_cidrs = optional(set(string), ["0.0.0.0/0"])
+ ipv6_cidrs = optional(set(string), [])
+ }), {})
+ }), {})
+ logging = optional(object({
+ retention_in_days = optional(number, 30)
+ kms_key_arn = optional(string, null)
+ log_group_class = optional(string, "STANDARD")
+ tags = optional(map(string), {})
+ }), {})
+ tags = optional(map(string), {})
}), {})
})
default = {}
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index bddc0873b4..940c5a3b50 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -1,6 +1,8 @@
variable "github_app" {
description = <map(object({
type = string
capabilities = object({
scale_set = object({
configuration_json = optional(string, "{}")
environment_variables = optional(map(string), {})
iam_statements = optional(map(object({
actions = set(string)
resources = set(string)
conditions = optional(list(object({
test = string
variable = string
values = set(string)
})), [])
})), {})
})
})
})) | n/a | yes |
+| [config\_store](#input\_config\_store) | Non-secret controller configuration storage. The module writes one SSM String parameter per reconciler below `path_prefix/object({
path_prefix = optional(string, null)
tier = optional(string, "Standard")
tags = optional(map(string), {})
}) | `{}` | no |
+| [container](#input\_container) | Scale-set controller image and runtime settings. A null image uses the internal official convenience image; production callers should use the release digest. Filesystem and Linux capability hardening are enforced by the module; health\_path is fixed at /healthz, the ECS liveness endpoint. | object({
image = optional(string, null)
user = optional(string, "10001:10001")
health_port = optional(number, 8080)
health_path = optional(string, "/healthz")
health_check_command = optional(list(string), null)
health_check_interval = optional(number, 30)
health_check_timeout = optional(number, 5)
health_check_retries = optional(number, 3)
health_check_start_period = optional(number, 30)
health_stale_after_seconds = optional(number, 180)
shutdown_timeout_seconds = optional(number, 110)
session_close_timeout_seconds = optional(number, 10)
reconnect_initial_backoff_seconds = optional(number, 1)
reconnect_max_backoff_seconds = optional(number, 30)
stop_timeout_seconds = optional(number, 120)
ecr_repository = optional(object({
arn = string
}), null)
}) | `{}` | no |
+| [ecs](#input\_ecs) | ECS substrate configuration. A managed cluster is created by default. For an external cluster, set `cluster.mode = "external"` and pass its ARN; the mode must be plan-known while the ARN may be computed. | object({
cluster = optional(object({
mode = optional(string, "managed")
arn = optional(string, null)
name = optional(string, null)
container_insights = optional(bool, true)
}), {})
task = optional(object({
cpu = optional(number, 512)
memory = optional(number, 1024)
cpu_architecture = optional(string, "X86_64")
ephemeral_storage = optional(object({
size_in_gib = number
}), null)
}), {})
service = optional(object({
platform_version = optional(string, "LATEST")
}), {})
iam = optional(object({
path = optional(string, "/")
permissions_boundary = optional(string, null)
}), {})
}) | `{}` | no |
+| [grouping](#input\_grouping) | Packing strategy for scale-set reconcilers. `compute_provider` creates one controller group per compute-provider type and is the default. `runner_config` creates one group per runner config. `custom` uses `custom.groups`; custom membership must cover every runner config exactly once.object({
strategy = optional(string, "compute_provider")
custom = optional(object({
groups = map(object({
runner_configs = set(string)
}))
}), null)
}) | `{}` | no |
+| [logging](#input\_logging) | CloudWatch Logs configuration. CloudWatch encrypts logs at rest with an AWS-owned key by default; set `kms_key_arn` to use a customer-managed key. | object({
retention_in_days = optional(number, 30)
kms_key_arn = optional(string, null)
log_group_class = optional(string, "STANDARD")
tags = optional(map(string), {})
}) | `{}` | no |
+| [network](#input\_network) | Private Fargate networking. Tasks never receive public IP addresses and the managed security groups have no ingress. HTTPS egress defaults to IPv4 Internet access because GitHub endpoints cannot be represented as security-group destinations; route it through controlled NAT, firewall, or proxy infrastructure when required. | object({
vpc_id = string
subnet_ids = set(string)
https_egress = optional(object({
ipv4_cidrs = optional(set(string), ["0.0.0.0/0"])
ipv6_cidrs = optional(set(string), [])
}), {})
}) | n/a | yes |
+| [prefix](#input\_prefix) | Stable prefix used for scale-set controller resources. | `string` | `"github-actions"` | no |
+| [runner\_configs](#input\_runner\_configs) | Normalized scale-set runner configurations keyed by stable runner-config name.map(object({
github = object({
config_url = string
app = object({
app_id = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
private_key = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
installation_id = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
})
force_ghes = optional(bool, null)
ssl_verify = optional(bool, true)
user_agent = optional(string, null)
})
scale_set = object({
name = string
id = number
runner_group_id = optional(number, null)
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
session_owner = optional(string, null)
})
work_folder = optional(string, null)
})) | n/a | yes |
+| [tags](#input\_tags) | Tags applied to scale-set orchestration resources. | `map(string)` | `{}` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [cluster](#output\_cluster) | Managed or external ECS cluster selected for all controller groups. |
+| [controller\_groups](#output\_controller\_groups) | Controller-group resources keyed by stable resolved group name. |
+| [reconciler\_config\_parameters](#output\_reconciler\_config\_parameters) | Non-secret SSM controller configuration parameters keyed by `object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
}) | n/a | yes |
-| [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. | object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
}) | n/a | yes |
+| [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. | object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = optional(list(object({ name = string, arn = string })), [null])
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
}) | n/a | yes |
| [lambda](#input\_lambda) | Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection. | object({
artifact = object({
s3 = object({
bucket = optional(string, null)
})
})
runtime = string
architecture = string
subnet_ids = list(string)
security_group_ids = list(string)
tags = optional(map(string), {})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
}) | n/a | yes |
| [observability](#input\_observability) | Common logging, tracing, and metrics configuration consumed by webhook controls. | object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
tags = optional(map(string), {})
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
}) | n/a | yes |
| [prefix](#input\_prefix) | Prefix used to identify resources created for this webhook orchestration provider. | `string` | n/a | yes |
diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md
index 9c6e4e0f52..c67f19ec3e 100644
--- a/modules/orchestration-providers/webhook/job-retry/README.md
+++ b/modules/orchestration-providers/webhook/job-retry/README.md
@@ -52,7 +52,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
-| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
ssm = object({
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
}) | n/a | yes |
+| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = optional(list(object({ name = string, arn = string })), [null])
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
ssm = object({
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
}) | n/a | yes |
## Outputs
diff --git a/modules/orchestration-providers/webhook/job-retry/variables.tf b/modules/orchestration-providers/webhook/job-retry/variables.tf
index e8235265f8..fe7e511289 100644
--- a/modules/orchestration-providers/webhook/job-retry/variables.tf
+++ b/modules/orchestration-providers/webhook/job-retry/variables.tf
@@ -87,7 +87,7 @@ variable "config" {
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
- installation_id = list(object({ name = string, arn = string }))
+ installation_id = optional(list(object({ name = string, arn = string })), [null])
})
})
queue = object({
diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md
index 877eec8039..f18cdd76e7 100644
--- a/modules/orchestration-providers/webhook/pool/README.md
+++ b/modules/orchestration-providers/webhook/pool/README.md
@@ -54,7 +54,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no |
-| [config](#input\_config) | Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler.object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
boot_time_in_minutes = number
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
}) | n/a | yes |
+| [config](#input\_config) | Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler.object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = optional(list(object({ name = string, arn = string })), [null])
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
boot_time_in_minutes = number
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
}) | n/a | yes |
| [runner\_provider](#input\_runner\_provider) | Compute provider integration used by the pool Lambda.object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
}) | n/a | yes |
| [tracing\_config](#input\_tracing\_config) | Tracing configuration for the pool Lambda.object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}) | `{}` | no |
diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf
index e1f516c8ad..d455f080a1 100644
--- a/modules/orchestration-providers/webhook/pool/variables.tf
+++ b/modules/orchestration-providers/webhook/pool/variables.tf
@@ -86,7 +86,7 @@ variable "config" {
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
- installation_id = list(object({ name = string, arn = string }))
+ installation_id = optional(list(object({ name = string, arn = string })), [null])
})
runner = object({
disable_runner_autoupdate = bool
diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md
index 3b096f9b85..7bd88312f3 100644
--- a/modules/orchestration-providers/webhook/scale-runners/README.md
+++ b/modules/orchestration-providers/webhook/scale-runners/README.md
@@ -67,7 +67,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM policy ARNs. | `string` | `"aws"` | no |
-| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-config.object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
}) | n/a | yes |
+| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-config.object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = optional(list(object({ name = string, arn = string })), [null])
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
}) | n/a | yes |
| [runner\_provider](#input\_runner\_provider) | Selected compute-provider integration for the scaling control plane.object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = string
additional_iam_policy_json = optional(string, null)
managed_policy = optional(object({
arn = string
}), null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = string
})
}) | n/a | yes |
## Outputs
diff --git a/modules/orchestration-providers/webhook/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf
index e191e303d1..eb4ef2dddf 100644
--- a/modules/orchestration-providers/webhook/scale-runners/variables.tf
+++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf
@@ -112,7 +112,7 @@ variable "config" {
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
- installation_id = list(object({ name = string, arn = string }))
+ installation_id = optional(list(object({ name = string, arn = string })), [null])
})
})
queue = object({
diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf
index 5dfecdbd6c..83bd6f30e6 100644
--- a/modules/orchestration-providers/webhook/variables.tf
+++ b/modules/orchestration-providers/webhook/variables.tf
@@ -169,7 +169,7 @@ variable "github" {
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
- installation_id = list(object({ name = string, arn = string }))
+ installation_id = optional(list(object({ name = string, arn = string })), [null])
})
enterprise_server = object({
url = optional(string, null)
diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md
new file mode 100644
index 0000000000..76f5489018
--- /dev/null
+++ b/modules/runner-config/README.md
@@ -0,0 +1,134 @@
+# Runner configuration module
+
+> This module is treated as an internal module; breaking changes do not trigger a major release bump.
+
+This internal module implements the experimental provider-neutral runner configuration selected by `multi_runner_config`. It is composed by `multi-runner` and is not intended as a standalone public entry point. Its direct contract may change while v2 remains experimental.
+
+The module selects the [`webhook` orchestration provider](../orchestration-providers/webhook), which owns its [`scale-runners`](../orchestration-providers/webhook/scale-runners), [`pool`](../orchestration-providers/webhook/pool), and [`job-retry`](../orchestration-providers/webhook/job-retry) leaves. The configuration module retains the common [`ssm-housekeeper`](./ssm-housekeeper), creates or selects the runner IAM role, manages shared runner configuration in SSM, and dispatches the selected compute provider.
+
+Runner demand orchestration is selected independently through `orchestration_provider`. `orchestration_provider.webhook` is the currently supported provider and owns the build queue reference; runner lifecycle, boot time, and capacity under `orchestration_provider.webhook.runner`; runner registration scope; scaling controls; scheduled pool; and job retry. Common `runner` contains no webhook lifecycle or capacity settings. The provider resolves its lifecycle contract before runner-config serializes the existing bootstrap parameters. The provider wrapper is nullable so a future sibling provider can be added without moving this webhook contract again, while validation requires exactly one provider to be selected.
+
+Common `lambda` contains only shared execution substrate and the optional shared artifact bucket. The webhook provider owns the runner-control archive shared by scale, pool, and job-retry at `orchestration_provider.webhook.lambda.artifact` and combines its zip or S3 key/version with that common substrate. The common SSM housekeeper independently owns `ssm.housekeeper.lambda.artifact`: an S3 selection combines its component key/version with the common bucket, a local zip is used otherwise when configured, and the packaged runner control-plane archive is the final fallback. It never inherits the webhook runner-control archive.
+
+Provider-owned settings remain nested under a typed namespace and provider leaf. For example, AMI, VPC, instance-profile, capacity, userdata, and runner-host logging settings live under `compute_provider.aws.ec2`. `multi-runner` resolves experimental globals and runner-configuration overrides first, then its final forwarding adapter preserves the wrapped `{ aws = { ec2 = ... } }` object expected by this module. Exactly one provider leaf must be non-null. The configuration module flattens the selected namespace and type to the Terraform dispatch key `aws_ec2`, while the webhook runtime registry continues to receive the provider type `ec2`.
+
+The EC2 leaf reaches runner-config with `compute_provider.aws.ec2.binaries_syncer = { enabled, s3 }`; the S3 object is null when synchronization is disabled. Binary discovery and this shape adaptation happen in `multi-runner`, not inside runner-config. Before creating the common runner role, the configuration module calls [`compute-providers/aws/ec2/trust-policy`](../compute-providers/aws/ec2/trust-policy) as `module.compute_aws_ec2_trust_policy[0]` to combine its default trust with `runner.iam.additional_trust_policy_json`. The resulting assume-role policy does not depend on the full [`compute-providers/aws/ec2`](../compute-providers/aws/ec2) module, dispatched at `module.compute_aws_ec2[0]`, which receives the resolved runner role only after it is created. Declarative moved blocks preserve state from the earlier experimental `module.compute_ec2_trust_policy[0]` and `module.compute_ec2[0]` labels. EC2 owns the instance profile, launch template, EC2 bootstrap parameters, runner log groups, and its provider policies and Lambda environment variables. The common configuration module attaches each returned policy group to its runner or webhook-provider role. Provider-specific outputs remain grouped under the matching namespace and provider path, currently `provider.aws.ec2`. Moved blocks do not rewrite output references, so consumers of the former experimental `provider.ec2` path must update their expressions. EC2 is the only implemented Terraform compute provider in this phase.
+
+## Tagging
+
+`tags` supplies module-wide defaults. Shared resource tags are set with `lambda.tags`, `orchestration_provider.webhook.queue.tags`, and `observability.logs.tags`. Component tags under `runner`, `orchestration_provider.webhook.lambda.scale.up`, `orchestration_provider.webhook.lambda.scale.down`, `orchestration_provider.webhook.lambda.pool`, `orchestration_provider.webhook.job_retry`, and `ssm` apply to the taggable resources owned by that component. `ssm.parameters.tags` and `ssm.housekeeper.tags` provide narrower SSM scopes.
+
+Tags are merged from broadest to narrowest: module tags, shared resource tags, component tags, and then subcomponent tags. The narrowest value wins when a key is repeated. For example, a scale-up Lambda receives `tags`, `lambda.tags`, and `orchestration_provider.webhook.lambda.scale.up.tags`, while its log group receives `tags`, `observability.logs.tags`, and `orchestration_provider.webhook.lambda.scale.up.tags`.
+
+Provider-specific runner tags remain inside the provider boundary. `compute_provider.aws.ec2.tags` applies to runtime EC2 instance, volume, network-interface, and spot-request tag specifications. `multi-runner` derives that map from global and runner-configuration `compute_provider.aws.ec2.tags` values. The EC2 provider applies the bootstrap tags `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` last so they cannot be overridden; those tags are not added to common Lambda, IAM, queue, log-group, or SSM resources.
+
+## Overview
+
+### Action runners on EC2
+
+The action runners are created via a launch template; in the launch template only the subnet needs to be provided. During launch the installation is handled via a user data script. The configuration is fetched from SSM parameter store.
+
+### Lambda scale up
+
+The scale up lambda is triggered by events on a SQS queue. Events on this queue are delayed, which will give the workflow some time to start running on available runners. For each event the lambda will check if the workflow is still queued and no other limits are reached. In that case the lambda will create a new EC2 instance. The lambda only needs to know which launch template to use and which subnets are available. From the available subnets a random one will be chosen. Once the instance is created the event is assumed as handled, and we assume the workflow wil start at some moment once the created instance is ready.
+
+### Lambda scale down
+
+The scale down lambda is triggered via a CloudWatch event. The event is triggered by a cron expression defined in `orchestration_provider.webhook.lambda.scale.down.schedule_expression` (https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html). For scaling down GitHub does not provide a good API yet, therefore we run the scaling down based on this event every x minutes. Each time the lambda is triggered it tries to remove all runners older than x minutes (configurable) managed in this deployment. In case the runner can be removed from GitHub, which means it is not executing a workflow, the lambda will terminate the EC2 instance.
+
+--8<-- "modules/orchestration-providers/webhook/scale-down-state-diagram.md:mkdocs_scale_down_state_diagram"
+
+## Lambda Function
+
+The Lambda function is written in [TypeScript](https://www.typescriptlang.org/) and requires Node 12.x and yarn. Sources are located in [./lambdas/runners]. Two lambda functions share the same sources, there is one entry point for `scaleDown` and another one for `scaleUp`.
+
+### Install
+
+```bash
+cd lambdas/runners
+yarn install
+```
+
+### Test
+
+Test are implemented with [vitest][https://vitest.dev/]), calls to AWS and GitHub are mocked.
+
+```bash
+yarn run test
+```
+
+### Package
+
+To compile all TypeScript/JavaScript sources in a single file [ncc](https://github.com/zeit/ncc) is used.
+
+```bash
+yarn run dist
+```
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
+| [aws](#requirement\_aws) | >= 6.33 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.33 |
+| [terraform](#provider\_terraform) | n/a |
+
+## Modules
+
+| Name | Source | Version |
+|------|--------|---------|
+| [compute\_aws\_ec2](#module\_compute\_aws\_ec2) | ../compute-providers/aws/ec2 | n/a |
+| [compute\_aws\_ec2\_trust\_policy](#module\_compute\_aws\_ec2\_trust\_policy) | ../compute-providers/aws/ec2/trust-policy | n/a |
+| [orchestration\_webhook](#module\_orchestration\_webhook) | ../orchestration-providers/webhook | n/a |
+| [ssm\_housekeeper](#module\_ssm\_housekeeper) | ./ssm-housekeeper | n/a |
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_iam_role.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
+| [aws_iam_role_policy.runner_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy_attachment.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
+| [aws_ssm_parameter.disable_default_labels](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.jit_config_enabled](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.runner_agent_mode](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.token_path](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [terraform_data.validate_config](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no |
+| [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes |
+| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.object({
aws = optional(object({
ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
vpc_id = string
subnet_ids = list(string)
overrides = optional(object({
name_runner = optional(string, "")
name_sg = optional(string, "")
}), {})
instance_profile = optional(object({
name = string
}), null)
instance_profile_path = optional(string, null)
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
id = string
key = string
}), null)
}), {})
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
ebs_optimized = optional(bool, false)
instance_target_capacity_type = optional(string, "spot")
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_types = list(string)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
ssm_enabled = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
managed_security_group_enabled = optional(bool, true)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
key_name = optional(string, null)
additional_security_group_ids = optional(list(string), [])
detailed_monitoring_enabled = optional(bool, false)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
tags = optional(map(string), {})
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
credit_specification = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
associate_public_ipv4_address = optional(bool, false)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
use_dedicated_host = optional(bool, false)
}), null)
}), {})
}) | n/a | yes |
+| [compute\_provider\_key](#input\_compute\_provider\_key) | Optional plan-known compute-provider dispatch key. Null discovers the key from the exactly one populated compute\_provider block. | `string` | `null` | no |
+| [github](#input\_github) | GitHub API and runner-registration configuration.object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = optional(list(object({ name = string, arn = string })), [null])
})
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)
scale_set = optional(object({
github = object({
config_url = string
installation_id_ssm = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
force_ghes = optional(bool, null)
})
name = string
id = number
runner_group_id = optional(number, null)
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
session_owner = optional(string, null)
work_folder = optional(string, null)
}), 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 |
+|------|-------------|
+| [compute\_provider\_contract](#output\_compute\_provider\_contract) | Provider-neutral compute-provider capabilities consumed by topology-level orchestration. |
+| [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..4d6c4c4444
--- /dev/null
+++ b/modules/runner-config/orchestration-provider.tf
@@ -0,0 +1,78 @@
+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"
+ scale_set = local.orchestration_provider_type == "scale_set"
+ }
+
+ orchestration_provider_runner_lifecycle = {
+ webhook = one(module.orchestration_webhook[*].runner_lifecycle)
+ scale_set = {
+ ephemeral = true
+ jit_config_enabled = true
+ }
+ }[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..39e511f9cd
--- /dev/null
+++ b/modules/runner-config/outputs.tf
@@ -0,0 +1,51 @@
+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
+ scale_set = local.orchestration_provider_enabled.scale_set ? var.orchestration_provider.scale_set : null
+ }
+}
+
+output "compute_provider_contract" {
+ description = "Provider-neutral compute-provider capabilities consumed by topology-level orchestration."
+ value = {
+ type = local.provider_contract.type
+ capabilities = local.provider_contract.capabilities
+ }
+}
+
+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 =